Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### Fixed

- Side effects like `CREATE TEMP TABLE` before the `VISUALISE` statement are now
separated from directly feeding into the visualisation data (#415)
- Fixed bug where panel axes were unintentionally anchored to zero when using
`FACET ... SETTING free => 'x'/'y'` (#410).

Expand Down
76 changes: 60 additions & 16 deletions src/execute/cte.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,35 +210,79 @@ pub fn split_with_query(source_tree: &SourceTree) -> Option<(String, String)> {
Some((cte_prefix, trailing))
}

/// Transform global SQL for execution with temp tables
/// Transform global SQL for execution with temp tables.
///
/// If the SQL has a WITH clause followed by SELECT, extracts just the SELECT
/// portion and transforms CTE references to temp table names.
/// For SQL without WITH clause, just transforms any CTE references.
/// Returns statements to execute directly as side effects (CREATE, INSERT, …)
/// and an optional query whose result should be wrapped as the global temp
/// table.
pub fn transform_global_sql(
source_tree: &SourceTree,
materialized_ctes: &HashSet<String>,
) -> Option<String> {
) -> (Vec<String>, Option<String>) {
// Collect side-effect statements (CREATE, INSERT, UPDATE, DELETE) that
// need to run before the main query. These appear alongside a trailing
// SELECT or VISUALISE FROM.
//
// Only structured DML is handled here — other_sql_statement nodes
// (INSTALL, LOAD, SET, …) are pre-executed in prepare_data_with_reader.
let root = source_tree.root();

let side_effect_stmts = r#"
(sql_statement
[(create_statement)
(insert_statement)
(update_statement)
(delete_statement)] @stmt)
"#;
let side_effects: Vec<String> = source_tree
.find_texts(&root, side_effect_stmts)
.into_iter()
.map(|s| transform_cte_references(s.trim(), materialized_ctes))
.filter(|s| !s.is_empty())
.collect();

// Try to extract trailing SELECT (WITH...SELECT or direct SELECT)
let select_sql = split_with_query(source_tree)
.map(|(_, select)| select)
.or_else(|| {
// Fallback: direct SELECT statement (no WITH clause)
let root = source_tree.root();
source_tree.find_text(&root, "(sql_statement (select_statement) @select)")
});

if let Some(select_sql) = select_sql {
Some(transform_cte_references(&select_sql, materialized_ctes))
} else if does_consume_cte(source_tree) {
// Non-SELECT executable SQL (CREATE, INSERT, UPDATE, DELETE)
// OR VISUALISE FROM (which injects SELECT * FROM <source>)
// Extract SQL (with injection if VISUALISE FROM) and transform CTE references
let sql = source_tree.extract_sql()?;
Some(transform_cte_references(&sql, materialized_ctes))
return (
side_effects,
Some(transform_cte_references(&select_sql, materialized_ctes)),
);
}

if !has_executable_sql(source_tree) {
return (vec![], None);
}

// We have non-SELECT executable SQL and/or VISUALISE FROM.
// Side-effects run directly, VISUALISE FROM becomes the queryable part.
// A bare WITH clause without a trailing statement is not executable on
// its own (its CTEs are already materialized separately).
let viz_from_query = source_tree
.find_text(
&root,
r#"(visualise_statement (from_clause (table_ref) @table))"#,
)
.map(|table| {
let q = format!("SELECT * FROM {}", table);
transform_cte_references(&q, materialized_ctes)
});

if !side_effects.is_empty() || viz_from_query.is_some() {
(side_effects, viz_from_query)
} else {
// No executable SQL (just CTEs)
None
// has_executable_sql was true but we found no specific statements or
// VISUALISE FROM — fall back to extract_sql as the query.
let query = source_tree
.extract_sql()
.map(|s| transform_cte_references(&s, materialized_ctes));
(vec![], query)
}
}

Expand All @@ -248,7 +292,7 @@ pub fn transform_global_sql(
/// This handles cases like `WITH a AS (...), b AS (...) VISUALISE` where the WITH
/// clause has no trailing SELECT - these CTEs are still extracted for layer use
/// but shouldn't be executed as global data.
pub fn does_consume_cte(source_tree: &SourceTree) -> bool {
pub fn has_executable_sql(source_tree: &SourceTree) -> bool {
let root = source_tree.root();

// Check for direct executable statements (SELECT, CREATE, INSERT, UPDATE,
Expand Down
81 changes: 77 additions & 4 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,9 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
));
}

// Execute setup statements (INSTALL, LOAD, SET, etc.) before the main query
// Execute setup statements (INSTALL, LOAD, SET, etc.) before the main query.
// Structured DML (CREATE, INSERT, UPDATE, DELETE) is handled separately as
// side-effects in cte::transform_global_sql.
for stmt in source_tree.find_texts(&root, "(sql_statement (other_sql_statement) @stmt)") {
execute_query(&stmt)?;
}
Expand All @@ -1063,16 +1065,21 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
// Execute global SQL if present
// If there's a WITH clause, extract just the trailing SELECT and transform CTE references.
// The global result is stored as a temp table so filtered layers can query it efficiently.
// Track whether we actually create the temp table (depends on transform_global_sql succeeding)
let mut has_global_table = false;
if sql_part.is_some() {
if let Some(transformed_sql) = cte::transform_global_sql(&source_tree, &materialized_ctes) {
let (side_effects, query) = cte::transform_global_sql(&source_tree, &materialized_ctes);

for stmt in &side_effects {
execute_query(stmt)?;
}

if let Some(query) = query {
// Materialize global result as a temp table directly on the backend
// (no roundtrip through Rust).
let statements = reader.dialect().create_or_replace_temp_table_sql(
&naming::global_table(),
&[],
&transformed_sql,
&query,
);
for stmt in &statements {
execute_query(stmt)?;
Expand Down Expand Up @@ -1895,6 +1902,72 @@ mod tests {
assert_eq!(result.data.get(layer1_key).unwrap().height(), 3);
}

#[cfg(feature = "duckdb")]
#[test]
fn test_visualise_from_after_create() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
CREATE TEMP TABLE data(x, y) AS (VALUES
('A', 5),
('B', 2),
('C', 4),
('D', 7),
('E', 6)
)
VISUALISE x, y FROM data
DRAW area
"#;

let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 5);
}

#[cfg(feature = "duckdb")]
#[test]
fn test_visualise_from_after_create_and_insert() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
CREATE TEMP TABLE data(x INTEGER, y INTEGER);
INSERT INTO data VALUES (1, 10), (2, 20), (3, 30);
VISUALISE x, y FROM data
DRAW point
"#;

let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 3);
}

#[cfg(feature = "duckdb")]
#[test]
fn test_select_after_create_and_insert() {
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();

let query = r#"
CREATE TEMP TABLE data(x INTEGER, y INTEGER);
INSERT INTO data VALUES (1, 10), (2, 20), (3, 30);
SELECT * FROM data
VISUALISE x, y
DRAW point
"#;

let result = prepare_data_with_reader(query, &reader).unwrap();
let key = result.specs[0].layers[0]
.data_key
.as_ref()
.expect("Layer should have data_key");
assert_eq!(result.data.get(key).unwrap().height(), 3);
}

/// Test that literal mappings survive stat transforms (e.g., histogram grouping).
///
/// This tests the fix for issue #129 where literal aesthetic columns like
Expand Down
8 changes: 4 additions & 4 deletions tree-sitter-ggsql/grammar.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ module.exports = grammar({

// INSERT statement
insert_statement: $ => prec.right(seq(
caseInsensitive('INSERT'),
token(prec(1, caseInsensitive('INSERT'))),
repeat1(choice(
$.sql_keyword,
$.identifier,
Expand All @@ -149,7 +149,7 @@ module.exports = grammar({

// UPDATE statement
update_statement: $ => prec.right(seq(
caseInsensitive('UPDATE'),
token(prec(1, caseInsensitive('UPDATE'))),
repeat1(choice(
$.sql_keyword,
$.identifier,
Expand All @@ -163,7 +163,7 @@ module.exports = grammar({

// DELETE statement
delete_statement: $ => prec.right(seq(
caseInsensitive('DELETE'),
token(prec(1, caseInsensitive('DELETE'))),
repeat1(choice(
$.sql_keyword,
$.identifier,
Expand All @@ -177,7 +177,7 @@ module.exports = grammar({

other_sql_statement: $ => prec(-1, repeat1(choice(
$.non_from_sql_keyword,
token(/[^\s;(),'"]+/),
/[^\s;(),'"]+/,
$.string,
$.number,
$.subquery,
Expand Down
126 changes: 126 additions & 0 deletions tree-sitter-ggsql/test/corpus/basic.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2865,6 +2865,132 @@ VISUALISE DRAW point MAPPING x AS x, y AS y
(bare_identifier))))
(aesthetic_name)))))))))

================================================================================
CREATE statement before VISUALISE FROM
================================================================================

CREATE TEMP TABLE data AS SELECT 1;
VISUALISE FROM data DRAW point

--------------------------------------------------------------------------------

(query
(sql_portion
(sql_statement
(create_statement
(select_statement
(select_body
(number))))))
(visualise_statement
(visualise_keyword)
(from_clause
(table_ref
(qualified_name
(identifier
(bare_identifier)))))
(viz_clause
(draw_clause
(geom_type)))))

================================================================================
INSERT statement before VISUALISE FROM
================================================================================

INSERT INTO data VALUES (1, 2);
VISUALISE FROM data DRAW point

--------------------------------------------------------------------------------

(query
(sql_portion
(sql_statement
(insert_statement)))
(visualise_statement
(visualise_keyword)
(from_clause
(table_ref
(qualified_name
(identifier
(bare_identifier)))))
(viz_clause
(draw_clause
(geom_type)))))

================================================================================
UPDATE statement before VISUALISE FROM
================================================================================

UPDATE data SET x = 1;
VISUALISE FROM data DRAW point

--------------------------------------------------------------------------------

(query
(sql_portion
(sql_statement
(update_statement)))
(visualise_statement
(visualise_keyword)
(from_clause
(table_ref
(qualified_name
(identifier
(bare_identifier)))))
(viz_clause
(draw_clause
(geom_type)))))

================================================================================
DELETE statement before VISUALISE FROM
================================================================================

DELETE FROM data WHERE x = 1;
VISUALISE FROM data DRAW point

--------------------------------------------------------------------------------

(query
(sql_portion
(sql_statement
(delete_statement)))
(visualise_statement
(visualise_keyword)
(from_clause
(table_ref
(qualified_name
(identifier
(bare_identifier)))))
(viz_clause
(draw_clause
(geom_type)))))

================================================================================
CREATE and INSERT before VISUALISE FROM
================================================================================

CREATE TABLE data (x INT);
INSERT INTO data VALUES (1);
VISUALISE FROM data DRAW point

--------------------------------------------------------------------------------

(query
(sql_portion
(sql_statement
(create_statement))
(sql_statement
(insert_statement)))
(visualise_statement
(visualise_keyword)
(from_clause
(table_ref
(qualified_name
(identifier
(bare_identifier)))))
(viz_clause
(draw_clause
(geom_type)))))

================================================================================
Arbitrary SQL setup statements
================================================================================
Expand Down
Loading