Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## [Unreleased]

### Fixed

- Side effects like `CREATE TEMP TABLE` before the `VISUALISE` statement are now
separated from directly feeding into the visualisation data (#415)

## 0.3.1 - 2026-04-30

### Fixed
Expand Down
70 changes: 56 additions & 14 deletions src/execute/cte.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,15 @@ 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>) {
// Try to extract trailing SELECT (WITH...SELECT or direct SELECT)
let select_sql = split_with_query(source_tree)
.map(|(_, select)| select)
Expand All @@ -229,16 +229,58 @@ pub fn transform_global_sql(
});

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 (
vec![],
Some(transform_cte_references(&select_sql, materialized_ctes)),
);
}

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

// We have non-SELECT executable SQL (CREATE, INSERT, …) and/or
// VISUALISE FROM. Split them: side-effect statements run directly,
// VISUALISE FROM becomes the queryable part.
//
// Only actual statements (CREATE, INSERT, …) are side effects — a bare
// WITH clause without a trailing statement is not executable on its own
// (its CTEs are already materialized separately).
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();

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
// does_consume_cte 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 Down
36 changes: 33 additions & 3 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1063,16 +1063,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 +1900,31 @@ 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);
}

/// 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
Loading