//! Eligibility classifier + materialization SQL codegen for managed `// materialize`. //! //! Managed `// materialize` (the default) promises the script is "setup //! statements, then one trailing SELECT" — Windmill generates the write DDL //! around that SELECT (the `// materialize manual` escape hatch opts out and //! writes its own DDL). This module is the single source of truth for *which //! block is that SELECT* and *what DDL gets generated*, so save-time validation //! (deploy path) and run-time codegen (DuckDB executor) can never disagree. //! //! Everything here is pure and string-level: no SQL is executed, no type //! inference is done. The classifier is leading-keyword based and deliberately //! conservative — anything it can't positively recognize as a read-only output //! or a known-safe setup statement is rejected, so a script is only accepted //! for managed mode when its shape is unambiguous. /// One top-level statement's role in a wrap-mode script. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlockClass { /// Read-only relation the wrap writes from: `SELECT` / `WITH …SELECT` / /// `FROM` (DuckDB from-first) / `VALUES` / `TABLE x` / `(UN)PIVOT`. Output, /// Known-safe preamble: `ATTACH` / `INSTALL` / `LOAD` / `SET` / `PRAGMA` / /// `USE` / `CREATE TEMP …`. Runs verbatim before the generated write. Setup, /// Anything that writes or whose effect we can't vouch for: non-temp /// `CREATE` / `INSERT` / `UPDATE` / `DELETE` / `MERGE` / `DROP` / `COPY` / /// `ALTER` / `TRUNCATE`, or an unrecognized leading keyword. Disqualifies /// managed mode (the user should use `// materialize manual`). Disallowed, } /// A script accepted for wrapping: zero+ setup blocks then one terminal SELECT. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WrapPlan { /// Setup statements in source order, verbatim, **without** trailing `;`. pub setup: Vec, /// The single terminal output statement, verbatim, **without** trailing `;`. pub output: String, } /// Why a script is not eligible for managed `// materialize`. Carries enough to /// render the targeted save-time messages. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WrapError { /// No statements at all (empty / comments only). Empty, /// No terminal SELECT — nothing to wrap. NoOutput, /// More than one top-level SELECT. `count` is how many were found. MultipleOutputs { count: usize }, /// A SELECT exists but isn't the last statement (something runs after it). OutputNotLast, /// A write/unknown statement appears among the setup blocks. `snippet` is a /// short prefix of the offending statement for the error message. DisallowedBlock { snippet: String }, } impl WrapError { /// Human-facing, actionable message (matches the spec's rejection text). pub fn message(&self) -> String { let base = "managed `// materialize` requires the script to be setup statements then a single trailing SELECT"; let manual = "use `// materialize manual` to write the DDL yourself"; match self { WrapError::Empty => format!("{base}: the script is empty."), WrapError::NoOutput => format!("{base}: found no SELECT — {manual}."), WrapError::MultipleOutputs { count } => format!( "{base}: found {count} SELECT statements; combine them with a CTE, or {manual}." ), WrapError::OutputNotLast => format!( "{base}: found statements after the SELECT — move them above it, or {manual}." ), WrapError::DisallowedBlock { snippet } => { format!("{base}: `{snippet}` writes or is unrecognized — {manual}.") } } } } /// Split SQL into top-level, `;`-separated statements, skipping line comments /// (`-- …`), block comments (`/* … */`), single-quoted strings (`'…'` with /// `''` escape) and double-quoted identifiers (`"…"`). Semicolons inside any of /// those are not separators. Returns each statement trimmed, comments stripped, /// empties dropped. Self-contained so the parser crate stays dependency-free; /// it must stay behaviourally aligned with the executor's block splitter (both /// route wrap through `classify_wrap`, so the split they see is this one). pub fn split_statements(sql: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); // Char-wise, not byte-wise: the emitted strings are re-executed // (materialize codegen) and persisted (macro registry), so a Latin-1 // `bytes[i] as char` decode would corrupt any multi-byte text inside // statements. All delimiters are ASCII — split positions are unaffected. let chars: Vec = sql.chars().collect(); let mut i = 0; let n = chars.len(); while i < n { let c = chars[i]; // line comment — `--` (SQL) or `//`. The `//` form is not SQL, but it // is how Windmill pipeline annotations (`// materialize`, `// pipeline`, // …) are written, and they sit above the SQL in the same script; strip // them so they don't pollute the first statement block's classification // or the generated setup SQL. if (c == '-' && i + 1 < n && chars[i + 1] == '-') || (c == '/' && i + 1 < n && chars[i + 1] == '/') { while i < n && chars[i] != '\n' { i += 1; } continue; } // block comment if c == '/' && i + 1 < n && chars[i + 1] == '*' { i += 2; while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') { i += 1; } i += 2; continue; } // single-quoted string if c == '\'' { cur.push(c); i += 1; while i < n { cur.push(chars[i]); if chars[i] == '\'' { // doubled '' is an escaped quote, stay in string if i + 1 < n && chars[i + 1] == '\'' { cur.push('\''); i += 2; continue; } i += 1; break; } i += 1; } continue; } // double-quoted identifier if c == '"' { cur.push(c); i += 1; while i < n { cur.push(chars[i]); if chars[i] == '"' { i += 1; break; } i += 1; } continue; } if c == ';' { let t = cur.trim(); if !t.is_empty() { out.push(t.to_string()); } cur.clear(); i += 1; continue; } cur.push(c); i += 1; } let t = cur.trim(); if !t.is_empty() { out.push(t.to_string()); } out } /// Lowercased top-level keyword tokens of a single statement (parens collapsed /// away: tokens *inside* balanced `(...)` are skipped, so a CTE body's verbs /// don't leak up). Strings/identifiers are already gone from the split, but we /// re-guard quotes defensively. Used to disambiguate `WITH …` and `CREATE …`. fn top_level_keywords(stmt: &str) -> Vec { let mut toks = Vec::new(); let mut cur = String::new(); let mut depth: i32 = 0; let bytes = stmt.as_bytes(); let mut i = 0; let n = bytes.len(); let flush = |cur: &mut String, toks: &mut Vec| { if !cur.is_empty() { toks.push(cur.to_lowercase()); cur.clear(); } }; while i < n { let c = bytes[i] as char; if c == '\'' || c == '"' { let q = bytes[i]; i += 1; while i < n && bytes[i] != q { i += 1; } i += 1; continue; } if c == '(' { flush(&mut cur, &mut toks); depth += 1; i += 1; continue; } if c == ')' { if depth > 0 { depth -= 1; } i += 1; continue; } if depth > 0 { i += 1; continue; } if c.is_alphanumeric() || c == '_' { cur.push(c); } else { flush(&mut cur, &mut toks); } i += 1; } flush(&mut cur, &mut toks); toks } const OUTPUT_KW: &[&str] = &["select", "from", "values", "table", "pivot", "unpivot"]; const SETUP_KW: &[&str] = &["attach", "install", "load", "set", "pragma", "use"]; const WRITE_VERBS: &[&str] = &["insert", "update", "delete", "merge"]; /// Classify a single statement by its leading keyword (with `WITH`/`CREATE` /// disambiguation). See [`BlockClass`]. pub fn classify_block(stmt: &str) -> BlockClass { let kws = top_level_keywords(stmt); let Some(first) = kws.first().map(String::as_str) else { return BlockClass::Disallowed; }; // CREATE TEMP … is setup (staging); any other CREATE is a write. if first == "create" { let temp = kws .iter() .skip(1) .take(3) .any(|k| k == "temp" || k == "temporary"); return if temp { BlockClass::Setup } else { BlockClass::Disallowed }; } // WITH … : the main statement's verb decides. CTE bodies are parenthesized, // so their verbs are not in `kws`; the first top-level write verb or SELECT // after the CTE list is the real one. if first == "with" { for k in kws.iter().skip(1) { if k == "select" { return BlockClass::Output; } if WRITE_VERBS.contains(&k.as_str()) { return BlockClass::Disallowed; } } // `WITH x AS (...) SELECT` where SELECT got collapsed is impossible // (SELECT here is top-level), so a WITH with no top-level verb is a // malformed/unknown statement — reject conservatively. return BlockClass::Disallowed; } if OUTPUT_KW.contains(&first) { return BlockClass::Output; } if SETUP_KW.contains(&first) { return BlockClass::Setup; } BlockClass::Disallowed } /// Validate a script for managed `// materialize` and, on success, return the /// setup/output split. Enforces the four conditions from the spec: /// 1. exactly one Output block, 2. it is last, 3. all preceding blocks are /// Setup, 4. nothing after it. pub fn classify_wrap(sql: &str) -> Result { let stmts = split_statements(sql); if stmts.is_empty() { return Err(WrapError::Empty); } let classes: Vec = stmts.iter().map(|s| classify_block(s)).collect(); let output_idxs: Vec = classes .iter() .enumerate() .filter(|(_, c)| **c == BlockClass::Output) .map(|(i, _)| i) .collect(); match output_idxs.len() { 0 => return Err(WrapError::NoOutput), 1 => {} count => return Err(WrapError::MultipleOutputs { count }), } let out_idx = output_idxs[0]; if out_idx != stmts.len() - 1 { return Err(WrapError::OutputNotLast); } // Everything before the output must be Setup (no Disallowed preamble). for (i, c) in classes.iter().enumerate().take(out_idx) { if *c != BlockClass::Setup { return Err(WrapError::DisallowedBlock { snippet: snippet(&stmts[i]) }); } } Ok(WrapPlan { setup: stmts[..out_idx].to_vec(), output: stmts[out_idx].clone() }) } fn snippet(stmt: &str) -> String { let one_line: String = stmt.split_whitespace().collect::>().join(" "); if one_line.chars().count() > 40 { let truncated: String = one_line.chars().take(40).collect(); format!("{truncated}…") } else { one_line } } // --------------------------------------------------------------------------- // Codegen // --------------------------------------------------------------------------- /// How a (partition of a) materialized table is reconciled on each run. /// Derived at deploy from the annotation: `key= history` (or the `scd2` /// alias) → `Scd2`, else `append` → `Append`, else `unique_key` → `Merge`, else /// `Replace`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MaterializeStrategy { /// DELETE the current partition, then INSERT — partition becomes exactly /// what the SELECT returned. Full-refresh of the slice. Replace, /// Upsert within the slice on `unique_key` (delete-by-key + insert); rows /// absent from the SELECT are left in place. This is SCD type 1: a changed /// row overwrites the prior value, keeping no history. Merge { unique_key: String }, /// INSERT only — immutable event-log semantics. Append, /// Slowly Changing Dimension type 2: the SELECT is the *current* snapshot /// (one row per `key`); a change to any tracked column closes the prior /// version (`valid_to`/`is_current=false`) and opens a new one, so the full /// history is preserved. `track` empty ⇒ every non-key column is tracked. /// `close_deleted` (opt-in `deletes=close`) also closes the current version /// of a key that disappears from the snapshot (dbt's `hard_deletes=close`); /// default (false) leaves absent keys current (soft delete). /// Unpartitioned only (the worker rejects `// partitioned` + scd2). Scd2 { key: String, track: Vec, close_deleted: bool }, } /// SCD2 metadata columns appended to the managed history table. Fixed names so /// the generated diff/close/open SQL and any `// data_test` on them agree. const SCD2_VALID_FROM: &str = "valid_from"; const SCD2_VALID_TO: &str = "valid_to"; const SCD2_IS_CURRENT: &str = "is_current"; /// Connection-local temp table holding the keys whose version must be rotated /// this run (changed + new). Captured before the write so the close and the /// open see the same set. `_wm_` prefix so it never collides with user tables. const SCD2_CHANGED_KEYS: &str = "_wm_scd2_changed"; /// Connection-local temp table holding the keys that disappeared from the /// snapshot this run (present-and-current in the table, absent from the SELECT). /// Only used when `close_deleted` (`deletes=close`) is set. const SCD2_DELETED_KEYS: &str = "_wm_scd2_deleted"; /// Inputs to materialization codegen, all resolved at run time by the worker. /// Pure: produces SQL text; executes nothing. #[derive(Debug, Clone)] pub struct MaterializeCodegen<'a> { /// Fully-qualified target, e.g. `_wm_target.orders_daily`. Always qualified /// so a user `USE …;` in setup can't redirect the write. pub target_qualified: &'a str, /// The user's output SELECT (verbatim, no trailing `;`) — embedded as a /// subquery so its own shape is irrelevant to the generated wrapper. pub select_sql: &'a str, /// Physical partition column added to the managed table. pub partition_col: &'a str, /// SQL expression for the current partition value — a literal like /// `'2026-06-19'` or a bind placeholder. The caller is responsible for /// safe quoting/binding. pub partition_value_sql: &'a str, /// Whether `// partitioned` applies. When false the table is unpartitioned /// and the partition column / `SET PARTITIONED BY` are omitted. pub partitioned: bool, pub strategy: MaterializeStrategy, /// Write-time guardrail for a drifted SELECT vs the fixed table schema. /// Only the persist-and-mutate strategies (partitioned replace, merge, /// append) act on it: `Fail` emits an in-txn guard that raises on drift, /// `Sync` writes BY NAME and expects the executor to inject `ALTER TABLE` /// DDL at the [`SYNC_ALTER_SENTINEL`] slot, `Warn`/`Ignore` write /// positionally (drift surfaced by the summary in `Warn`, silent in /// `Ignore`). See [`MaterializeCodegen::is_persist_and_mutate`]. pub on_schema_change: OnSchemaChange, } /// The exact statement the `sync` codegen emits right after `BEGIN /// TRANSACTION;` as the injection slot for `ALTER TABLE … ADD/DROP COLUMN` /// DDL. The executor computes the drift with a pre-pass probe and replaces this /// literal in the assembled query text (with the DDL, or removes it when there /// is no drift). Classified `Write` so the EE write-audit-publish reassembly /// keeps it inside the transaction with the mutations; a plain no-op SELECT so /// that if it is somehow left un-replaced the run still succeeds unchanged. pub const SYNC_ALTER_SENTINEL: &str = "SELECT '__wm_sync_alter_sentinel__' AS _wm_sync;"; impl<'a> MaterializeCodegen<'a> { /// Whether this (strategy, partitioned) uses the positional persist-and- /// mutate write whose table schema is fixed at first CREATE — the only case /// the `on_schema_change` write-time guardrail applies to. Whole-table /// replace (`CREATE OR REPLACE`) and scd2 self-heal / are name-mapped, so /// they are excluded. pub fn is_persist_and_mutate(&self) -> bool { match self.strategy { MaterializeStrategy::Scd2 { .. } => false, MaterializeStrategy::Replace => self.partitioned, MaterializeStrategy::Append | MaterializeStrategy::Merge { .. } => true, } } /// The ordered statements that perform the materialization, to be run after /// the setup blocks and inside the caller's execution. The first-run /// bootstrap is idempotent (`IF NOT EXISTS`), so this is safe to run every /// time. The DELETE/INSERT body is wrapped in one transaction so a partial /// failure leaves the prior snapshot intact. Every strategy reduces to /// DELETE+INSERT (no `MERGE INTO`) — see the `Merge` arm for why. pub fn statements(&self) -> Vec { let t = self.target_qualified; let sel = self.select_sql; let pcol = self.partition_col; let pval = self.partition_value_sql; let mut out = Vec::new(); // SCD2 has a shape unlike the DELETE/INSERT strategies (diff → close old // → open new) and does not support partitioning (rejected at the worker), // so it is generated up front by its own helper. if let MaterializeStrategy::Scd2 { key, track, close_deleted } = &self.strategy { return self.scd2_statements(key, track, *close_deleted); } // Whole-table replace: rebuild the table to match the SELECT's *current* // schema each run with one atomic `CREATE OR REPLACE` (which DuckLake // still snapshots). This is the only path that survives a changed SELECT // or a pre-existing table with a different schema — the persist-and- // mutate paths below fix the schema at first create. if !self.partitioned && matches!(self.strategy, MaterializeStrategy::Replace) { out.push(format!( "CREATE OR REPLACE TABLE {t} AS SELECT * FROM ({sel});" )); return out; } // Persist-and-mutate (partitioned, or merge/append): bootstrap the table // if absent, then write into it. The schema is fixed at first create — // a later SELECT-schema change needs a manual rebuild (schema evolution // is a follow-up). if self.partitioned { out.push(format!( "CREATE TABLE IF NOT EXISTS {t} AS \ SELECT *, CAST(NULL AS VARCHAR) AS {pcol} FROM ({sel}) WHERE false;" )); out.push(format!("ALTER TABLE {t} SET PARTITIONED BY ({pcol});")); } else { out.push(format!( "CREATE TABLE IF NOT EXISTS {t} AS SELECT * FROM ({sel}) WHERE false;" )); } out.push("BEGIN TRANSACTION;".to_string()); // Write-time schema guardrail, emitted right after BEGIN so it runs // before any mutation (a failing guard aborts before touching data; the // sync ALTERs run before the INSERT so the sets match). Reached only on // the persist-and-mutate path here (whole-table replace and scd2 return // above), so no extra strategy gate is needed. match self.on_schema_change { OnSchemaChange::Fail => out.push(schema_drift_guard_sql(sel, t, pcol)), OnSchemaChange::Sync => out.push(SYNC_ALTER_SENTINEL.to_string()), OnSchemaChange::Warn | OnSchemaChange::Ignore => {} } // The rows to write, with the partition column appended when partitioned. let source = if self.partitioned { format!("SELECT *, {pval} AS {pcol} FROM ({sel})") } else { format!("SELECT * FROM ({sel})") }; // `sync` maps columns by name (positional would cross-wire: an ALTERed // ADD COLUMN appends at the end, so a positional INSERT of the SELECT // would fill it from the wrong source column). let by_name = if self.on_schema_change == OnSchemaChange::Sync { " BY NAME" } else { "" }; match &self.strategy { MaterializeStrategy::Replace => { // Only reached when partitioned (whole-table replace returned above). out.push(format!("DELETE FROM {t} WHERE {pcol} = {pval};")); out.push(format!("INSERT INTO {t}{by_name} {source};")); } MaterializeStrategy::Append => { out.push(format!("INSERT INTO {t}{by_name} {source};")); } MaterializeStrategy::Merge { unique_key } => { // Upsert within the slice via delete-by-key + insert (dbt's // `delete+insert`): rows whose key is in the incoming SELECT are // replaced, others are left in place. This deliberately avoids // `MERGE INTO` — DuckLake's MERGE fails writing the first rows of // a fresh partition (HTTP 404 on the new parquet), and a failed // write leaves the table needing a DROP. DELETE+INSERT is the // same write shape as `replace`, which is reliable. The DELETE is // scoped to the current partition when partitioned so it stays // slice-local (a key present in another partition is untouched). // // Guard first: the DELETE+INSERT does not dedup the source, so // two incoming rows sharing a key would both persist under it. // Raise instead of silently double-writing (see the helper). out.push(duplicate_source_key_guard_sql(sel, unique_key)); let scope = if self.partitioned { format!("{pcol} = {pval} AND ") } else { String::new() }; out.push(format!( "DELETE FROM {t} WHERE {scope}{unique_key} IN (SELECT {unique_key} FROM ({sel}));" )); out.push(format!("INSERT INTO {t}{by_name} {source};")); } // Handled by the early return above (scd2 has no partitioned form). MaterializeStrategy::Scd2 { .. } => unreachable!("scd2 handled before this match"), } out.push("COMMIT;".to_string()); out } /// SCD2 codegen: the incoming SELECT is the *current desired snapshot* (one /// row per `key`); we diff it against the live current rows, close the prior /// version of every changed/new key, and open a fresh one — so history is /// kept. `track` empty ⇒ every non-key column is tracked for change /// detection. /// /// Shape (all one transaction for the mutation, mirroring the other /// strategies so a partial failure leaves the prior snapshot intact): /// 1. bootstrap the table (business columns + `valid_from/valid_to/ /// is_current`), idempotent; /// 2. capture changed+new keys into a connection-local temp table *before* /// the write — the close below flips `is_current`, so recomputing the /// diff after it would see a different set; /// 3. close the prior open version of those keys (`UPDATE` — not `MERGE`: /// DuckLake's MERGE is the unreliable path, plain UPDATE works); /// 3b. when `close_deleted`, also capture the keys that vanished from the /// snapshot and close their current version (no reopen) — dbt's /// `hard_deletes=close`; /// 4. open a new current version from the snapshot; /// 5. create the `_current` convenience view once (`IF NOT EXISTS`), /// inside the same transaction so it doesn't advance the DuckLake snapshot /// past the data write the summary records (and so an unchanged rerun, /// whose UPDATE/INSERT touch no rows, stays a true no-op). /// /// Close/open match keys with `IS NOT DISTINCT FROM` (via a correlated /// `EXISTS`), not `key IN (…)`: SQL `IN` never matches `NULL`, so a `NULL` /// natural key would be flagged as changed yet silently skipped by both the /// close and the open, dropping the row. Null-safe matching materializes it /// instead (a `NULL` key is still ill-formed for a dimension — guard it with /// `// data_test not_null ` — but it must not vanish). /// /// Without `close_deleted`, keys present in the table but absent from the /// SELECT are left current (soft delete — dbt's `hard_deletes=ignore` default; /// with `close_deleted` they are closed instead — see step 3b). The effective /// timestamp is `now()`, which DuckDB fixes to /// the transaction start, so `valid_from`/`valid_to` are consistent within a /// run without a nondeterministic per-statement clock. /// /// Reserved columns: `valid_from`/`valid_to`/`is_current` are appended to the /// user's SELECT with these fixed names (kept clean so consumers write /// `WHERE is_current` / `ASOF JOIN … >= valid_from`). A SELECT that already /// projects one of them is a v1 constraint violation — the bootstrap then /// produces a duplicate output column and the run fails at execution /// (documented; not statically checkable here since the SELECT's columns /// aren't known at codegen time). fn scd2_statements(&self, key: &str, track: &[String], close_deleted: bool) -> Vec { let t = self.target_qualified; let sel = self.select_sql; let k = quote_ident(key); let vf = SCD2_VALID_FROM; let vt = SCD2_VALID_TO; let ic = SCD2_IS_CURRENT; let changed = SCD2_CHANGED_KEYS; let deleted = SCD2_DELETED_KEYS; // Transaction-stable effective timestamp (see doc above). Cast to plain // TIMESTAMP so it matches the bootstrapped column type (now() is TZ-aware). let ts = "CAST(now() AS TIMESTAMP)"; // Projection compared to detect change. Empty `track` ⇒ all business // columns via `* EXCLUDE ()` on the table side (which carries // the extra metadata columns) and `*` on the snapshot side. An explicit // `track` ⇒ key + those columns on both sides. `EXCEPT` treats NULLs as // equal, so an unchanged NULL is not read as a change. let (src_proj, tgt_proj) = if track.is_empty() { ( format!("SELECT * FROM ({sel})"), format!("SELECT * EXCLUDE ({vf}, {vt}, {ic}) FROM {t} WHERE {ic}"), ) } else { let cols = std::iter::once(key) .chain(track.iter().map(String::as_str)) .map(quote_ident) .collect::>() .join(", "); ( format!("SELECT {cols} FROM ({sel})"), format!("SELECT {cols} FROM {t} WHERE {ic}"), ) }; let mut out = vec![ format!( "CREATE TABLE IF NOT EXISTS {t} AS SELECT *, \ CAST(NULL AS TIMESTAMP) AS {vf}, \ CAST(NULL AS TIMESTAMP) AS {vt}, \ CAST(NULL AS BOOLEAN) AS {ic} FROM ({sel}) WHERE false;" ), format!( "CREATE OR REPLACE TEMP TABLE {changed} AS \ SELECT {k} FROM ({src_proj} EXCEPT {tgt_proj});" ), ]; // Hard-delete-close (`deletes=close`): the keys that vanished from the // snapshot — present-and-current in the table, absent from the SELECT. // Captured before the close (like `changed`) and disjoint from it (a // key is either in the snapshot or not), so the two closes never overlap. if close_deleted { out.push(format!( "CREATE OR REPLACE TEMP TABLE {deleted} AS \ SELECT {k} FROM (SELECT {k} FROM {t} WHERE {ic} EXCEPT SELECT {k} FROM ({sel}));" )); } out.push("BEGIN TRANSACTION;".to_string()); out.push(format!( "UPDATE {t} SET {vt} = {ts}, {ic} = false \ WHERE {ic} AND EXISTS (SELECT 1 FROM {changed} \ WHERE {changed}.{k} IS NOT DISTINCT FROM {t}.{k});" )); // Close vanished keys — no matching INSERT below, so they close without // reopening. A key that later reappears isn't in `WHERE is_current`, so the // `changed` diff treats it as new and opens a fresh version (a validity gap // between the delete and the reactivation — correct SCD2). if close_deleted { out.push(format!( "UPDATE {t} SET {vt} = {ts}, {ic} = false \ WHERE {ic} AND EXISTS (SELECT 1 FROM {deleted} \ WHERE {deleted}.{k} IS NOT DISTINCT FROM {t}.{k});" )); } out.push(format!( "INSERT INTO {t} SELECT s.*, {ts} AS {vf}, CAST(NULL AS TIMESTAMP) AS {vt}, \ true AS {ic} FROM ({sel}) s WHERE EXISTS (SELECT 1 FROM {changed} c \ WHERE c.{k} IS NOT DISTINCT FROM s.{k});" )); out.push( // Consumer convenience: a `_current` view (the live slice) so the // common "just the latest version" read needs no `WHERE is_current`, // and downstream scripts can `// on` / read it directly. For the // effective-dated payoff, consumers `ASOF JOIN ON fact.key = // dim. AND fact.ts >= dim.valid_from`. // // `CREATE VIEW IF NOT EXISTS` (not `OR REPLACE`), created inside the // write transaction, on purpose: the view definition never changes // (`SELECT * WHERE is_current` always reflects live data), and a // catalog write advances the DuckLake snapshot — so `OR REPLACE` on // every run would (a) advance the snapshot on an otherwise no-op // unchanged run and (b) make the summary's `max(snapshot_id)` record // the view DDL instead of the data write. `IF NOT EXISTS` creates it // once (folded into the first data-write snapshot) and is a true no-op // afterwards. The `_current` name is reserved: if a real table by // that name already exists, `IF NOT EXISTS` skips silently (no view, // no error) — documented as a reserved suffix. format!("CREATE VIEW IF NOT EXISTS {t}_current AS SELECT * FROM {t} WHERE {ic};"), ); out.push("COMMIT;".to_string()); out } } // --------------------------------------------------------------------------- // on_schema_change drift detection (write-time guardrail) // --------------------------------------------------------------------------- // // Drift is computed entirely in SQL against the live DuckDB session: the // SELECT's output columns come from `DESCRIBE`, the table's from `DESCRIBE` of // the target (the managed `_wm_partition` column is excluded so it is compared // as the producer's logical output, matching schema capture). `added` = SELECT // columns absent from the table, `removed` = table columns absent from the // SELECT. When the table was just created this run (first materialize) the two // DESCRIBEs agree, so both lists are empty and no guard fires. /// A `DESCRIBE`-derived set of column names of `rel_sql` (any SELECT-able /// relation, already parenthesized/qualified by the caller), optionally /// dropping the managed partition column. fn describe_col_names(rel_sql: &str, exclude_col: Option<&str>) -> String { let filter = match exclude_col { Some(c) => format!(" WHERE column_name <> {}", quote_lit(c)), None => String::new(), }; format!("SELECT column_name FROM (DESCRIBE SELECT * FROM {rel_sql}){filter}") } /// Scalar subqueries `(added, removed)` — the list of column names in the /// SELECT but not the table, and vice versa. Each is a `list(...)` over an /// `EXCEPT`; empty ⇒ `list()` yields an empty list (`len` 0). The partition /// column is excluded on the table side only. /// /// Set difference, not ordered: this catches the add/remove/rename that a /// positional INSERT misaligns on, but by design NOT a pure reorder of /// same-named columns (identical sets ⇒ empty added/removed). See the /// `OnSchemaChange` doc in asset_parser.rs — reorder-safety is `sync`'s job /// (INSERT BY NAME); the set difference is deliberately kept over an ordered /// comparison so the `fail` guard cannot false-positive on a correctly-aligned /// write. fn drift_lists(sel_sql: &str, target_qualified: &str, partition_col: &str) -> (String, String) { let sel = describe_col_names(&format!("({sel_sql})"), None); let tbl = describe_col_names(target_qualified, Some(partition_col)); let added = format!("(SELECT list(column_name) FROM (({sel}) EXCEPT ({tbl})))"); let removed = format!("(SELECT list(column_name) FROM (({tbl}) EXCEPT ({sel})))"); (added, removed) } /// The `fail`-mode guard: a single statement that raises via DuckDB `error(...)` /// when the SELECT's columns diverge from the table's, naming the added/removed /// columns and the target. Both `CASE` branches are cast to VARCHAR so the /// planner cannot constant-fold the `error(...)` away, and the condition depends /// on the runtime drift subqueries so it is never folded to a constant. fn schema_drift_guard_sql(sel_sql: &str, target_qualified: &str, partition_col: &str) -> String { let (added, removed) = drift_lists(sel_sql, target_qualified, partition_col); // `target_qualified` is safe in table-reference position (quoted identifiers) // but here it lands inside a SQL string literal, so single-quotes must be // doubled (same as `quote_lit`). let tq = target_qualified.replace('\'', "''"); format!( "SELECT CASE WHEN coalesce(len(_wm_added), 0) + coalesce(len(_wm_removed), 0) > 0 \ THEN CAST(error('managed materialize: on_schema_change=fail blocked a schema-drifted \ write to {tq} — added column(s): [' || coalesce(array_to_string(_wm_added, ', '), '') || \ '], removed column(s): [' || coalesce(array_to_string(_wm_removed, ', '), '') || \ ']. The table schema is fixed at first create; set on_schema_change=sync to auto-migrate, \ or align the SELECT with the table.') AS VARCHAR) ELSE 'ok' END \ FROM (SELECT {added} AS _wm_added, {removed} AS _wm_removed);", tq = tq, ) } /// In-transaction guard for the keyed `merge` strategy: raises via DuckDB /// `error(...)` when the source SELECT holds more than one row for the same /// non-NULL `unique_key`. A keyed merge is delete-by-key + insert-all (it does /// NOT deduplicate the source), so duplicate source keys would land every /// duplicate row under one key — the exact silent double-write this guards /// against. Erroring keeps the semantics explicit: the author must deduplicate /// in the SELECT (or use `append`). NULL keys are excluded to match the delete's /// `key IN (...)` scope, which never matches NULL. `unique_key` is embedded raw /// in identifier position (matching the merge's own DELETE/IN) and doubled-quote /// escaped where it lands inside the error string literal. fn duplicate_source_key_guard_sql(sel_sql: &str, unique_key: &str) -> String { let key_lit = unique_key.replace('\'', "''"); format!( "SELECT CASE WHEN _wm_dup_keys > 0 THEN CAST(error('managed materialize: keyed merge on \ `{key_lit}` blocked — the source has ' || _wm_dup_keys || ' key value(s) with more than \ one row. A keyed merge keeps one row per key and does not deduplicate; deduplicate in the \ SELECT (e.g. QUALIFY row_number() OVER (PARTITION BY {key_lit} ORDER BY …) = 1) or use \ `append`.') AS VARCHAR) ELSE 'ok' END FROM (SELECT count(*) AS _wm_dup_keys FROM (SELECT \ {unique_key} FROM ({sel_sql}) WHERE {unique_key} IS NOT NULL GROUP BY {unique_key} HAVING \ count(*) > 1));" ) } /// The `warn`-mode summary column: a `schema_drift` struct `{added, removed}` /// when the SELECT drifted from the table, else NULL. Appended to the /// materialize summary row so the executor can log it and fold it into the job /// result without an extra round-trip. fn schema_drift_summary_field( sel_sql: &str, target_qualified: &str, partition_col: &str, ) -> String { let (added, removed) = drift_lists(sel_sql, target_qualified, partition_col); format!( "(SELECT CASE WHEN coalesce(len(_wm_added), 0) + coalesce(len(_wm_removed), 0) > 0 \ THEN {{'added': _wm_added, 'removed': _wm_removed}} END \ FROM (SELECT {added} AS _wm_added, {removed} AS _wm_removed)) AS schema_drift" ) } /// The read that captures the DuckLake snapshot id produced by the write, for /// the given attach alias (e.g. `_wm_target`). The worker runs this last and /// records the result into `materialized_partition`. pub fn snapshot_capture_sql(alias: &str) -> String { format!("SELECT max(snapshot_id) AS snapshot_id FROM ducklake_snapshots('{alias}');") } /// Reserved attach alias for the materialization target, fully-qualified in all /// generated SQL so a user `USE …;` in the setup blocks can't redirect the /// write. The worker resolves the real `ATTACH 'ducklake:…' AS _wm_target (…)` /// from the target ducklake's config and passes it in as `target_attach`. pub const TARGET_ALIAS: &str = "_wm_target"; /// Structural role of one statement in a [`MaterializePlan`]. The public build /// executes the plan verbatim, so the kinds are pure metadata there; they exist /// so a downstream assembler (`pipeline_advanced::finalize_materialize_query`) /// can reason about the plan without parsing SQL. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MaterializeStmtKind { /// Pre-write statement: user setup, the target ATTACH, referenced-asset /// ATTACHes. Setup, /// Write work against the target: bootstrap DDL, SCD2 temp-table captures, /// the mutation itself, the `_current` view. Write, /// The `BEGIN TRANSACTION;` marker emitted by the strategy codegen. TxnBegin, /// The `COMMIT;` marker emitted by the strategy codegen. TxnCommit, /// The trailing one-row summary read (asset / rows / snapshot_id / /// data_tests breakdown). Summary, } /// One planned statement: its structural role and the SQL text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializeStmt { pub kind: MaterializeStmtKind, pub sql: String, } /// The full ordered materialization plan [`build_wrap_blocks`] produces: /// statements in execution order plus the compiled data-test checks (also /// embedded in the summary statement's breakdown). Assembled into the final /// statement list by `pipeline_advanced::finalize_materialize_query`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MaterializePlan { pub stmts: Vec, pub checks: Vec, } /// Assemble the full ordered statement list the DuckDB executor runs for a /// managed `// materialize` script. This is the single entry point the worker /// calls; it composes the already-tested pieces (classifier split → target /// ATTACH → strategy codegen → snapshot capture) so their ordering lives in one /// tested place rather than inline in the executor. /// /// `target_attach` is the real `ATTACH 'ducklake:…' AS _wm_target (…);` string /// the worker built from config (it depends on resolved credentials, so it /// can't be generated here). `target_table` is the table within that catalog /// (e.g. `orders_daily`), referenced as `_wm_target.`. `asset_path` is /// the full `/
` for the result summary. The trailing statement is /// a one-row summary read (asset / rows / snapshot_id) that is both the job's /// result (a useful preview) and what the worker records. /// /// Returns a [`MaterializePlan`] — the statements plus their structural role /// and the compiled data-test checks — rather than raw SQL: the executor hands /// the plan to `windmill_common::pipeline_advanced::finalize_materialize_query` /// (which this crate cannot depend on), whose public-build implementation /// assembles the statements verbatim. Everything this function produces runs /// as-is on the public build; the plan's structure is metadata about it, not a /// second mode. pub fn build_wrap_blocks( plan: &WrapPlan, target_attach: &str, target_table: &str, asset_path: &str, partition_col: &str, partition_value_sql: &str, partitioned: bool, strategy: MaterializeStrategy, on_schema_change: OnSchemaChange, tests: &[DataTestResolved], ) -> Result { let target_qualified = format!("{TARGET_ALIAS}.{target_table}"); let scd2 = matches!(strategy, MaterializeStrategy::Scd2 { .. }); let ctx = DataTestCtx { target_qualified: &target_qualified, asset_path, partition_col, partition_value_sql, partitioned, scd2, }; let test_sql = build_data_test_checks(tests, &ctx)?; let cg = MaterializeCodegen { target_qualified: &target_qualified, select_sql: &plan.output, partition_col, partition_value_sql, partitioned, strategy, on_schema_change, }; // `warn` folds the post-write drift into the summary row (executor logs it + // returns it) — only for the positional persist-and-mutate path, and only in // `warn`: `fail`/`sync` guard the write itself, `ignore` is silent. let drift_summary_select = if on_schema_change == OnSchemaChange::Warn && cg.is_persist_and_mutate() { Some(plan.output.as_str()) } else { None }; let mut stmts: Vec = Vec::new(); let setup = |sql: String| MaterializeStmt { kind: MaterializeStmtKind::Setup, sql }; // Setup blocks come from the splitter with their `;` stripped — re-terminate // each so that when the executor re-joins and re-splits the assembled query, // adjacent statements (e.g. the user ATTACH and the synthetic target ATTACH) // don't merge into one malformed statement. stmts.extend(plan.setup.iter().map(|s| setup(terminate(s)))); stmts.push(setup(target_attach.to_string())); // Referenced-asset ATTACHes (relationships tests) — read-only, before the // write and the summary that probes them. stmts.extend(test_sql.attaches.into_iter().map(setup)); // Classify the codegen statements by matching the exact transaction-marker // literals this module emits (`BEGIN TRANSACTION;` / `COMMIT;`); everything // else the codegen produces is write work. stmts.extend(cg.statements().into_iter().map(|sql| { let kind = match sql.as_str() { "BEGIN TRANSACTION;" => MaterializeStmtKind::TxnBegin, "COMMIT;" => MaterializeStmtKind::TxnCommit, _ => MaterializeStmtKind::Write, }; MaterializeStmt { kind, sql } })); // The summary read carries the per-test breakdown (when any tests apply). stmts.push(MaterializeStmt { kind: MaterializeStmtKind::Summary, sql: materialize_result_sql( &target_qualified, asset_path, partition_col, partition_value_sql, partitioned, &test_sql.checks, drift_summary_select, ), }); Ok(MaterializePlan { stmts, checks: test_sql.checks }) } /// The trailing one-row summary the materialize run returns: the asset it /// produced, the row count of the materialized slice (the partition when /// partitioned, else the whole table), and the DuckLake snapshot it created. /// This is both a useful preview result and the row the worker records. pub fn materialize_result_sql( target_qualified: &str, asset_path: &str, partition_col: &str, partition_value_sql: &str, partitioned: bool, checks: &[DataTestCheck], // `on_schema_change=warn` on a persist-and-mutate strategy: the SELECT to // diff against the (post-write) table for the `schema_drift` summary column. // `None` ⇒ no drift column (every other mode / strategy). drift_summary_select: Option<&str>, ) -> String { let (count_expr, partition_sel) = if partitioned { // Row count is the slice this run wrote (the partition); `partition` // lets the UI label the count and scope the preview to it. ( format!( "(SELECT count(*) FROM {target_qualified} WHERE {partition_col} = {partition_value_sql})" ), format!("{partition_value_sql} AS partition, "), ) } else { ( format!("(SELECT count(*) FROM {target_qualified})"), String::new(), ) }; // Capture the materialized output schema (gap #2a) in the same summary row — // no extra round-trip. `DESCRIBE SELECT * FROM ` yields one row per // column (`column_name`, `column_type`); fold them into a list-of-struct the // worker reads back and persists as asset metadata. The write just // committed, so the latest snapshot (no `AT (VERSION)` needed) is exactly the // slice recorded in `snapshot_id`. // // Two correctness details: // - `_wm_ord` (a `row_number()` over the DESCRIBE) is captured so the // list-of-struct is ordered *explicitly* (`list(... ORDER BY _wm_ord)`). // DESCRIBE returns columns in physical order; without the explicit ORDER // the `list()` aggregate could reorder them and spuriously bump the schema // version on a re-materialize. // - For a `// partitioned` asset the physical table carries the synthetic // `_wm_partition` column; it must be filtered out so the recorded schema is // the producer's logical output, not Windmill's storage detail (this is the // grain #2b contract enforcement reads back). let partition_filter = if partitioned { format!( " WHERE column_name <> '{}'", partition_col.replace('\'', "''") ) } else { String::new() }; let schema_capture = format!( "(SELECT list({{'name': column_name, 'type': column_type}} ORDER BY _wm_ord) \ FROM (SELECT column_name, column_type, row_number() OVER () AS _wm_ord \ FROM (DESCRIBE SELECT * FROM {target_qualified}){partition_filter})) AS output_schema" ); // `on_schema_change=warn`: fold the drift `{added, removed}` (or NULL) into // the same row so the executor logs it + returns it with no extra probe. let drift_col = match drift_summary_select { Some(sel) => format!( ", {}", schema_drift_summary_field(sel, target_qualified, partition_col) ), None => String::new(), }; let base_cols = format!( "'ducklake://{asset_path}' AS materialized, \ {partition_sel}{count_expr} AS rows, \ (SELECT max(snapshot_id) FROM ducklake_snapshots('{TARGET_ALIAS}')) AS snapshot_id, \ {schema_capture}{drift_col}" ); if checks.is_empty() { return format!("SELECT {base_cols};"); } // Per-test breakdown. Each check's one-row probe `(v, s)` becomes a CTE // (`_wm_t0`, `_wm_t1`, …); `_wm_tr` cross-joins them (all one-row, so the // join stays one row) and the `data_tests` list-of-struct references the // flattened columns — DuckDB rejects scalar subqueries *inside* a // struct/list literal, hence the CTE lift. Names are single-quote-escaped. // The result row carries the whole breakdown so the worker runs every // test (no abort-on-first) and decides pass/fail itself. let probe_ctes = checks .iter() .enumerate() .map(|(i, c)| format!("_wm_t{i} AS ({})", c.probe)) .collect::>() .join(", "); let tr_cols = checks .iter() .enumerate() .map(|(i, _)| format!("_wm_t{i}.v AS c{i}, _wm_t{i}.s AS s{i}")) .collect::>() .join(", "); let tr_from = checks .iter() .enumerate() .map(|(i, _)| format!("_wm_t{i}")) .collect::>() .join(", "); let list_items = checks .iter() .enumerate() .map(|(i, c)| { let name = c.name.replace('\'', "''"); format!("{{'test': '{name}', 'violating': c{i}, 'sample': s{i}}}") }) .collect::>() .join(", "); format!( "WITH {probe_ctes}, _wm_tr AS (SELECT {tr_cols} FROM {tr_from}) \ SELECT {base_cols}, [{list_items}] AS data_tests FROM _wm_tr;" ) } // Ensure a statement ends with a single `;`. /// Convenience macro injected as the first setup statement of a partitioned /// materialize: `wm_partition(ts)` renders a timestamp with the SAME identity /// format the resolver used for `{partition}` (from /// [`PartitionSpec::time_strftime_format`]). It lets a partitioned SELECT /// filter to the active slice with a single grain-agnostic line — /// `WHERE wm_partition() = {partition}` — so users never hand-write a /// `strftime` format that can drift, nor reach for `= TIMESTAMP {partition}` /// (which only parses for daily). `None` for `dynamic` (no wall-clock bucket). /// /// Timezone-agnostic by construction: it formats `ts` as given, matching the /// prior documented `strftime(ts, fmt)` idiom. When a non-UTC `tz=` is set the /// caller is responsible for expressing `ts` in that zone (same caveat the raw /// idiom carried), so this doesn't silently reinterpret a column's instant. pub fn wm_partition_macro(spec: &crate::asset_parser::PartitionSpec) -> Option { let fmt = spec.time_strftime_format()?; // fmt is a trusted per-grain constant or the author's `format=`; escape // single quotes defensively so the emitted literal can't break out. Some(format!( "CREATE OR REPLACE TEMP MACRO wm_partition(ts) AS strftime(ts, '{}')", fmt.replace('\'', "''") )) } fn terminate(stmt: &str) -> String { let t = stmt.trim_end(); if t.ends_with(';') { t.to_string() } else { format!("{t};") } } // --------------------------------------------------------------------------- // Data tests (`// data_test`) // --------------------------------------------------------------------------- // // A data test is the FIRST extensible annotation: the parser yields a // `DataTest` from a known vocabulary, and this module turns each into a // *check* — a `(name, probe)` pair whose probe counts and samples the // violating rows — that runs against the freshly-materialized target after // the write commits. The materialize summary query embeds every check's // outcome in one `data_tests` column, so all tests run in a single pass (no // abort-on-first) and the worker, not the SQL, decides pass/fail and reports // the full per-test breakdown. // // The pattern is deliberately open: a verifier is just `(name, violating-rows // query)` handed to `push_check`. Built-ins differ only in their rows query; // the `Custom` escape hatch supplies its own (a user SELECT returning the // violating rows). A sibling annotation family (column-lineage) can emit its // own checks through the same `push_check` shape rather than bolting on a // parallel mechanism. See `docs/ducklake-materialization.md`. use crate::asset_parser::{AssetKind, DataTest, OnSchemaChange}; /// Target context a data-test probe runs against — the materialized table and /// the partition slice (when partitioned, tests are scoped to the slice just /// written, so a rerun/backfill is independent of other partitions' data). #[derive(Debug, Clone)] pub struct DataTestCtx<'a> { /// Fully-qualified materialized target, e.g. `_wm_target.orders`. pub target_qualified: &'a str, /// `/
` of the target, for human-readable probe messages. pub asset_path: &'a str, /// Physical partition column on the managed table. pub partition_col: &'a str, /// SQL literal/expression for the current partition value (already escaped). pub partition_value_sql: &'a str, /// Whether the target is partitioned (scopes probes to the slice). pub partitioned: bool, /// Whether the target is an SCD2 history table. Built-in probes then assert /// the *current snapshot* (`is_current` rows): the history legitimately /// repeats the natural key across closed versions, so an unscoped /// `unique()` would fail on the second change of any key. pub scd2: bool, } /// A data test resolved enough to generate SQL. Built-ins carry only their /// parsed `DataTest`; `Custom` additionally carries the fetched script body /// (the parser crate can't fetch it — the worker does and passes it in). #[derive(Debug, Clone)] pub enum DataTestResolved { BuiltIn(DataTest), Custom { path: String, body: String }, } /// One compiled data-test check: a human-readable `name` and a one-row probe /// query yielding `(v, s)` — the violating-row count (0 = pass) and a bounded /// `to_json` sample of the violating rows as a VARCHAR (NULL when there are /// none, or when the serialized sample exceeds the size cap). The materialize /// summary query embeds every check's outcome so the worker gets the whole /// breakdown in one result — all tests run (no abort-on-first) and the /// worker, not the SQL, decides pass/fail. The sample is decoration only: /// enforcement reads `v`, never `s`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataTestCheck { pub name: String, /// One-row subquery `SELECT … AS v, … AS s FROM (…)` counting and /// sampling the check's violating rows in a single scan. pub probe: String, } /// Row cap on a data-test sample. Bounded so the sample stays a debugging aid /// (the full count is still reported); no ORDER BY on the violating rows, so /// which rows land in the sample is nondeterministic. const SAMPLE_MAX_ROWS: usize = 20; /// Byte cap on one serialized sample. Oversized samples are dropped entirely /// (NULL), never truncated — truncated JSON would fail parsing downstream /// after paying the bytes anyway. const SAMPLE_MAX_BYTES: usize = 51_200; /// The SQL a set of data tests compiles to: referenced-asset `ATTACH` /// statements (resolved by the executor's ATTACH-transform pass) and the /// per-test checks, both in declaration order. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DataTestChecks { pub attaches: Vec, pub checks: Vec, } /// Alias prefix for a relationships test's referenced asset, attached /// read-only alongside the target. `_wm_ref_` so it never collides with the /// user's aliases or the reserved `_wm_target`. const REF_ALIAS_PREFIX: &str = "_wm_ref_"; // Double-quote a SQL identifier, escaping embedded quotes — so an arbitrary // column name from an annotation can't break out of the identifier. fn quote_ident(id: &str) -> String { format!("\"{}\"", id.replace('"', "\"\"")) } // Quote a possibly schema-qualified table reference (`schema.table`) by quoting // each dotted segment independently: `main.dim_products` → `"main"."dim_products"`. // Quoting the whole thing would make DuckDB read it as one table name containing // a literal dot, querying the wrong table. fn quote_qualified(name: &str) -> String { name.split('.') .map(quote_ident) .collect::>() .join(".") } // Single-quote a SQL string literal, escaping embedded quotes. fn quote_lit(s: &str) -> String { format!("'{}'", s.replace('\'', "''")) } // The `WHERE`/`AND` fragment scoping a probe to the current partition and/or // the SCD2 current snapshot, or empty when neither applies. `prefix` is // `WHERE ` or `AND ` per call site; further conditions chain with `AND`. // (`partitioned` and `scd2` are mutually exclusive today — the combo is // rejected at codegen — but the chaining keeps this correct if that changes.) fn partition_scope(ctx: &DataTestCtx, prefix: &str, table_alias: Option<&str>) -> String { let qualify = |col: String| match table_alias { Some(a) => format!("{a}.{col}"), None => col, }; let mut conds: Vec = Vec::new(); if ctx.partitioned { conds.push(format!( "{} = {}", qualify(quote_ident(ctx.partition_col)), ctx.partition_value_sql )); } if ctx.scd2 { conds.push(qualify(SCD2_IS_CURRENT.to_string())); } if conds.is_empty() { return String::new(); } format!("{prefix}{}", conds.join(" AND ")) } // Record one check: its display `name` plus `rows_query`, the SELECT of its // violating rows. The probe counts and samples those rows in one scan: // `_wm_v` (the subquery alias) referenced as a column is the whole row as a // struct; `to_json(...)::VARCHAR` keeps the sample a JSON *string* through // the FFI — expanded rows would be visible to the executor's key-recursive // `extract_i64(result, "rows"/"snapshot_id")` scans, which a user column of // the same name could corrupt. `list()` over zero rows and an over-cap // sample both degrade to NULL (`s` is optional by contract). fn push_check(out: &mut DataTestChecks, name: String, rows_query: String) { // `strlen` counts bytes (unlike `length`, characters) — the cap bounds // payload size on the wire, so bytes are the right unit. let probe = format!( "SELECT v, CASE WHEN strlen(s_raw) <= {SAMPLE_MAX_BYTES} THEN s_raw END AS s \ FROM (SELECT count(*) AS v, \ to_json(list(_wm_v ORDER BY _wm_rn) FILTER (WHERE _wm_rn <= {SAMPLE_MAX_ROWS}))::VARCHAR AS s_raw \ FROM (SELECT _wm_v, row_number() OVER () AS _wm_rn FROM ({rows_query}) _wm_v))" ); out.checks.push(DataTestCheck { name, probe }); } // `SELECT *` for a sample rows-query, excluding the synthetic physical // partition column on partitioned targets — it's Windmill's storage detail, // not part of the producer's logical output (same rule as schema capture). // `qualifier` scopes the star when the query aliases the target (`_wm_src`). fn sample_star(ctx: &DataTestCtx, qualifier: Option<&str>) -> String { let star = match qualifier { Some(q) => format!("{q}.*"), None => "*".to_string(), }; if ctx.partitioned { format!("{star} EXCLUDE ({})", quote_ident(ctx.partition_col)) } else { star } } // Self-teaching tail appended to every malformed-custom-test error. It states // the two rules that aren't documented or scaffolded anywhere else — the body // is a single SELECT, and it reads the freshly-materialized target through the // internal `_wm_target.
` alias — and doubles that alias into a copyable // one-line example. `target_qualified` is already `_wm_target.
`. fn custom_test_hint(target_qualified: &str) -> String { format!( "Write a single SELECT against `{target_qualified}` returning the offending rows, e.g. \ `SELECT * FROM {target_qualified} WHERE ` — an empty result means the test \ passes." ) } // Whether a custom-test statement reads the materialized target through the // reserved `_wm_target` alias (the only handle the runtime attaches it under). // SQL identifiers are case-insensitive, so match case-insensitively; // `split_statements` has already stripped comments, so a match here is a real // reference, not one buried in a comment. `TARGET_ALIAS` is lowercase. fn references_target(stmt: &str) -> bool { stmt.to_lowercase().contains(TARGET_ALIAS) } /// Compile resolved data tests into ATTACH statements + per-test checks for /// `ctx`'s target. Pure: returns SQL text, executes nothing. Errors carry an /// actionable message (e.g. a relationships target that isn't an attachable /// table). pub fn build_data_test_checks( tests: &[DataTestResolved], ctx: &DataTestCtx, ) -> Result { let t = ctx.target_qualified; let mut out = DataTestChecks::default(); // Dedup ref attaches by (kind, name): a database can't be attached twice, // so multiple relationships into the same db share one alias. let mut ref_aliases: Vec<(AssetKind, String, String)> = Vec::new(); for resolved in tests { match resolved { DataTestResolved::BuiltIn(DataTest::Unique { column }) => { let c = quote_ident(column); let scope = partition_scope(ctx, " AND ", None); // The rows are the GROUP BY result — one `{value, count}` per // duplicated key — so the count (number of duplicated values, // not of rows) and the sample share one grain and can't // contradict each other in the UI. let q = format!( "SELECT {c} AS \"value\", count(*) AS \"count\" FROM {t} \ WHERE {c} IS NOT NULL{scope} GROUP BY {c} HAVING count(*) > 1" ); push_check(&mut out, format!("unique({column})"), q); } DataTestResolved::BuiltIn(DataTest::NotNull { column }) => { let c = quote_ident(column); let scope = partition_scope(ctx, " AND ", None); let star = sample_star(ctx, None); let q = format!("SELECT {star} FROM {t} WHERE {c} IS NULL{scope}"); push_check(&mut out, format!("not_null({column})"), q); } DataTestResolved::BuiltIn(DataTest::AcceptedValues { column, values }) => { let c = quote_ident(column); let scope = partition_scope(ctx, " AND ", None); let star = sample_star(ctx, None); let list = values .iter() .map(|v| quote_lit(v)) .collect::>() .join(", "); let q = format!( "SELECT {star} FROM {t} WHERE {c} IS NOT NULL AND {c} NOT IN ({list}){scope}" ); push_check(&mut out, format!("accepted_values({column})"), q); } DataTestResolved::BuiltIn(DataTest::Relationships { column, to_kind, to_path, to_column, }) => { let (ref_name, ref_table) = to_path.split_once('/').ok_or_else(|| { format!("data_test relationships: target `{to_path}` must be `/
`") })?; if ref_table.is_empty() { return Err(format!( "data_test relationships: target `{to_path}` has no table" )); } let scheme = match to_kind { AssetKind::Ducklake => "ducklake", AssetKind::DataTable => "datatable", other => { return Err(format!( "data_test relationships: target kind {other:?} is not an attachable \ table (use ducklake:// or datatable://)" )) } }; // The materialize target's ducklake is already attached as // `_wm_target`; a reference into that same lake must reuse it // rather than ATTACH the same database again under a fresh alias // (DuckDB forbids attaching one database twice). `asset_path` is // the target's `/
`, so its lake is the part before // the first `/`. let target_lake = ctx.asset_path.split('/').next().unwrap_or(""); let alias = if *to_kind == AssetKind::Ducklake && ref_name == target_lake { TARGET_ALIAS.to_string() } else { // Reuse an existing alias for the same (kind, name), else mint one. match ref_aliases .iter() .find(|(k, n, _)| k == to_kind && n == ref_name) { Some((_, _, a)) => a.clone(), None => { let a = format!("{REF_ALIAS_PREFIX}{}", ref_aliases.len()); // Escape the name — it is interpolated into a // single-quoted DuckDB literal (defense-in-depth: the // name is deploy-time annotation content, but the parser // places no character restriction on asset paths). let esc_name = ref_name.replace('\'', "''"); out.attaches .push(format!("ATTACH '{scheme}://{esc_name}' AS {a};")); ref_aliases.push((*to_kind, ref_name.to_string(), a.clone())); a } } }; let c = quote_ident(column); let rc = quote_ident(to_column); // `ref_table` may be schema-qualified (`schema.table`); quote each // segment so the dot stays a schema separator, not a literal. let rt = quote_qualified(ref_table); let scope = partition_scope(ctx, " AND ", Some("_wm_src")); let star = sample_star(ctx, Some("_wm_src")); let q = format!( "SELECT {star} FROM {t} _wm_src \ WHERE _wm_src.{c} IS NOT NULL{scope} \ AND NOT EXISTS (SELECT 1 FROM {alias}.{rt} _wm_ref \ WHERE _wm_ref.{rc} = _wm_src.{c})" ); push_check( &mut out, format!("relationships({column} -> {to_path}.{to_column})"), q, ); } // A parsed Custom must be resolved (body fetched) before codegen. DataTestResolved::BuiltIn(DataTest::Custom { path }) => { return Err(format!( "data_test custom `{path}`: body not resolved before codegen (internal)" )); } DataTestResolved::Custom { path, body } => { // dbt singular-test convention: the body is a *single* SELECT // (or CTE) returning the violating rows, reading the // freshly-materialized target through the internal `_wm_target` // schema. It is embedded as a subquery (`FROM ()`), so a // multi-statement or non-SELECT body would produce invalid SQL. // Neither rule is documented or scaffolded elsewhere, so the // errors are self-teaching: they name the exact violation and // append a correct one-line example. It runs in the target's // connection (can read `_wm_target` + the user's attaches); // partition substitution is already applied by the worker. let hint = custom_test_hint(t); let stmts = split_statements(body); if stmts.is_empty() { return Err(format!( "data_test custom `{path}`: empty test body. {hint}" )); } if stmts.len() > 1 { return Err(format!( "data_test custom `{path}`: a custom data test must be a single SELECT, \ but found {} statements. {hint}", stmts.len() )); } let stmt = &stmts[0]; if classify_block(stmt) != BlockClass::Output { return Err(format!( "data_test custom `{path}`: a custom data test must be a single SELECT, \ not a write or DDL statement. {hint}" )); } if !references_target(stmt) { return Err(format!( "data_test custom `{path}`: the test never reads the freshly-materialized \ target — reference it through the internal `{TARGET_ALIAS}` schema (as \ `{t}`), not the table name on its own. {hint}" )); } push_check(&mut out, format!("custom({path})"), stmt.to_string()); } } } Ok(out) } #[cfg(test)] mod tests { use super::*; fn ok(sql: &str) -> WrapPlan { classify_wrap(sql).expect("expected wrap-eligible") } fn err(sql: &str) -> WrapError { classify_wrap(sql).expect_err("expected wrap-ineligible") } use crate::asset_parser::{PartitionKind, PartitionSpec}; fn pspec(kind: PartitionKind, format: Option<&str>) -> PartitionSpec { PartitionSpec { kind, tz: None, format: format.map(String::from), start: None } } #[test] fn wm_partition_macro_uses_grain_identity_format() { // Every time grain's macro must strftime with the exact format the // resolver stamps the identity with — otherwise the equality filter // silently returns no rows. This is the whole point of the shared source. let cases = [ (PartitionKind::Daily, "%Y-%m-%d"), (PartitionKind::Hourly, "%Y-%m-%dT%H"), (PartitionKind::Weekly, "%G-W%V"), (PartitionKind::Monthly, "%Y-%m"), ]; for (kind, fmt) in cases { assert_eq!( wm_partition_macro(&pspec(kind.clone(), None)).as_deref(), Some( format!( "CREATE OR REPLACE TEMP MACRO wm_partition(ts) AS strftime(ts, '{fmt}')" ) .as_str() ), "wrong macro format for {kind:?}" ); } } #[test] fn wm_partition_macro_honors_format_override_and_skips_dynamic() { // Explicit `format=` wins over the grain default. assert!( wm_partition_macro(&pspec(PartitionKind::Hourly, Some("%Y/%m/%d %H"))) .unwrap() .contains("strftime(ts, '%Y/%m/%d %H')") ); // Dynamic has no wall-clock bucket → no macro (user filters on their key). assert_eq!( wm_partition_macro(&pspec( PartitionKind::Dynamic { key: "$.tenant".into() }, None )), None ); } #[test] fn split_respects_strings_comments_idents() { let sql = "SET x=1; -- a; comment\nSELECT ';' AS a, \"weird;col\" /* ; */ FROM t;"; let s = split_statements(sql); assert_eq!(s.len(), 2); assert_eq!(s[0], "SET x=1"); assert!(s[1].starts_with("SELECT")); assert!(s[1].contains("\"weird;col\"")); } #[test] fn split_handles_escaped_quote() { let s = split_statements("SELECT 'it''s; fine' AS a;"); assert_eq!(s.len(), 1); assert!(s[0].contains("it''s; fine")); } #[test] fn pipeline_annotations_are_stripped() { // The real shape: `//` annotation lines above the SQL must not pollute // the first block's classification (regression — they were being read // as a leading `pipeline` keyword and rejected). let p = ok("// pipeline\n// materialize ducklake://main/t\n// partitioned daily\nATTACH 'ducklake://main' AS dl;\nSELECT 1 AS id"); assert_eq!(p.setup.len(), 1); // The annotation lines are gone — the setup block starts at the real // SQL (the `//` inside `ducklake://main` is legitimately retained). assert!(p.setup[0].starts_with("ATTACH")); assert!(p.output.starts_with("SELECT")); } #[test] fn bare_select_is_eligible() { let p = ok("SELECT a, b FROM t WHERE c = '{partition}'"); assert!(p.setup.is_empty()); assert!(p.output.starts_with("SELECT")); } #[test] fn setup_then_select_is_eligible() { let p = ok( "ATTACH 'ducklake://main' AS dl;\n SET memory_limit='4GB';\n SELECT * FROM dl.orders", ); assert_eq!(p.setup.len(), 2); assert!(p.output.starts_with("SELECT")); } #[test] fn create_temp_staging_is_setup() { let p = ok("CREATE TEMP TABLE s AS SELECT 1; SELECT * FROM s"); assert_eq!(p.setup.len(), 1); assert_eq!( classify_block("CREATE TEMP TABLE s AS SELECT 1"), BlockClass::Setup ); assert_eq!( classify_block("CREATE OR REPLACE TEMPORARY VIEW v AS SELECT 1"), BlockClass::Setup ); } #[test] fn with_cte_select_is_output_write_is_disallowed() { assert_eq!( classify_block("WITH x AS (SELECT 1) SELECT * FROM x"), BlockClass::Output ); // CTE whose main statement inserts is a write, even though it starts WITH. assert_eq!( classify_block("WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x"), BlockClass::Disallowed ); } #[test] fn from_first_and_values_are_output() { assert_eq!(classify_block("FROM t SELECT a"), BlockClass::Output); assert_eq!(classify_block("VALUES (1),(2)"), BlockClass::Output); assert_eq!(classify_block("TABLE t"), BlockClass::Output); } #[test] fn trailing_write_rejected() { assert_eq!( err("SELECT * FROM t; INSERT INTO u VALUES (1)"), WrapError::OutputNotLast ); } #[test] fn write_in_preamble_rejected() { match err("INSERT INTO t VALUES (1); SELECT * FROM t") { WrapError::DisallowedBlock { snippet } => assert!(snippet.starts_with("INSERT")), e => panic!("wrong error: {e:?}"), } } #[test] fn multiple_selects_rejected() { assert_eq!( err("SELECT 1; SELECT 2"), WrapError::MultipleOutputs { count: 2 } ); } #[test] fn no_select_and_empty_rejected() { assert_eq!(err("CREATE TABLE t (a INT)"), WrapError::NoOutput); assert_eq!(err(" -- just a comment\n"), WrapError::Empty); } #[test] fn use_cannot_redirect_is_classified_setup() { // `USE` is allowed setup; generated SQL is fully qualified regardless. assert_eq!(classify_block("USE dl"), BlockClass::Setup); } #[test] fn codegen_replace_partitioned() { let cg = MaterializeCodegen { target_qualified: "_wm_target.orders_daily", select_sql: "SELECT a FROM dl.orders", partition_col: "_wm_partition", partition_value_sql: "'2026-06-19'", partitioned: true, strategy: MaterializeStrategy::Replace, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); assert!(st[0].contains("CREATE TABLE IF NOT EXISTS _wm_target.orders_daily")); assert!(st[0].contains("CAST(NULL AS VARCHAR) AS _wm_partition")); assert!(st.iter().any( |s| s == "ALTER TABLE _wm_target.orders_daily SET PARTITIONED BY (_wm_partition);" )); assert!(st.iter().any(|s| s.starts_with( "DELETE FROM _wm_target.orders_daily WHERE _wm_partition = '2026-06-19'" ))); assert!(st.iter().any(|s| s.contains( "INSERT INTO _wm_target.orders_daily SELECT *, '2026-06-19' AS _wm_partition" ))); assert_eq!(st.first().map(|_| &st[st.len() - 1]).unwrap(), "COMMIT;"); } #[test] fn codegen_merge_is_delete_by_key_plus_insert() { let cg = MaterializeCodegen { target_qualified: "_wm_target.orders_daily", select_sql: "SELECT order_id, amount FROM dl.orders", partition_col: "_wm_partition", partition_value_sql: "'2026-06-19'", partitioned: true, strategy: MaterializeStrategy::Merge { unique_key: "order_id".to_string() }, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); // upsert = delete-by-key (partition-scoped) + insert — NO `MERGE INTO` // (DuckLake's MERGE fails on fresh partitions). assert!(!st.iter().any(|s| s.contains("MERGE INTO"))); let del = st .iter() .find(|s| s.starts_with("DELETE FROM")) .expect("delete stmt"); assert!(del.contains( "WHERE _wm_partition = '2026-06-19' AND order_id IN (SELECT order_id FROM (SELECT order_id, amount FROM dl.orders))" )); assert!(st .iter() .any(|s| s.starts_with("INSERT INTO _wm_target.orders_daily SELECT *, '2026-06-19'"))); } #[test] fn codegen_merge_emits_duplicate_source_key_guard() { // A keyed merge does not dedup its source, so codegen must emit a guard // that fails the write when the SELECT has >1 row per key — otherwise // duplicate source keys silently double-write. let cg = MaterializeCodegen { target_qualified: "_wm_target.orders_daily", select_sql: "SELECT order_id, amount FROM dl.orders", partition_col: "_wm_partition", partition_value_sql: "'2026-06-19'", partitioned: false, strategy: MaterializeStrategy::Merge { unique_key: "order_id".to_string() }, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); let guard = st .iter() .find(|s| s.contains("_wm_dup_keys")) .expect("duplicate-key guard stmt"); // raises via error(), counts non-NULL keys appearing more than once assert!(guard.contains("error('managed materialize: keyed merge on `order_id` blocked")); assert!(guard.contains("GROUP BY order_id HAVING count(*) > 1")); assert!(guard.contains("WHERE order_id IS NOT NULL")); // must run before the mutations so a violation aborts before any write let guard_pos = st.iter().position(|s| s.contains("_wm_dup_keys")).unwrap(); let del_pos = st .iter() .position(|s| s.starts_with("DELETE FROM")) .unwrap(); let ins_pos = st .iter() .position(|s| s.starts_with("INSERT INTO")) .unwrap(); assert!(guard_pos < del_pos && guard_pos < ins_pos); } #[test] fn codegen_append_inserts_only() { let cg = MaterializeCodegen { target_qualified: "_wm_target.events", select_sql: "SELECT * FROM dl.raw", partition_col: "_wm_partition", partition_value_sql: "'2026-06-19'", partitioned: true, strategy: MaterializeStrategy::Append, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); assert!(st .iter() .any(|s| s.starts_with("INSERT INTO _wm_target.events"))); assert!(!st.iter().any(|s| s.starts_with("DELETE"))); assert!(!st.iter().any(|s| s.starts_with("MERGE"))); } #[test] fn codegen_whole_table_replace_is_create_or_replace() { // Unpartitioned replace must use CREATE OR REPLACE so a changed SELECT // schema (or a pre-existing table with a different schema) doesn't break // — and nothing else (no bootstrap / DELETE / INSERT / txn). let cg = MaterializeCodegen { target_qualified: "_wm_target.customer_dim", select_sql: "SELECT a, b, c FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy: MaterializeStrategy::Replace, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); assert_eq!( st, vec![ "CREATE OR REPLACE TABLE _wm_target.customer_dim AS SELECT * FROM (SELECT a, b, c FROM dl.src);" .to_string() ] ); } #[test] fn codegen_scd2_default_track_closes_old_opens_new() { // Empty `track` ⇒ diff on all business columns via `* EXCLUDE (scd cols)`. let cg = MaterializeCodegen { target_qualified: "_wm_target.dim_scd2", select_sql: "SELECT id, name FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy: MaterializeStrategy::Scd2 { key: "id".to_string(), track: vec![], close_deleted: false, }, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); // bootstrap adds the three SCD metadata columns assert!(st[0].starts_with("CREATE TABLE IF NOT EXISTS _wm_target.dim_scd2 AS SELECT *,")); assert!(st[0].contains("AS valid_from")); assert!(st[0].contains("AS valid_to")); assert!(st[0].contains("AS is_current")); // changed-key set captured before the transaction, all cols compared assert!( st[1].contains("CREATE OR REPLACE TEMP TABLE _wm_scd2_changed AS SELECT \"id\" FROM") ); assert!(st[1].contains("SELECT * FROM (SELECT id, name FROM dl.src) EXCEPT")); assert!(st[1].contains("SELECT * EXCLUDE (valid_from, valid_to, is_current) FROM _wm_target.dim_scd2 WHERE is_current")); assert_eq!(st[2], "BEGIN TRANSACTION;"); // close: UPDATE (not MERGE) the prior open version of changed keys, with // null-safe key matching (IS NOT DISTINCT FROM, not IN — IN drops NULLs) assert!(st[3].starts_with("UPDATE _wm_target.dim_scd2 SET valid_to = CAST(now() AS TIMESTAMP), is_current = false")); assert!(st[3].contains( "WHERE is_current AND EXISTS (SELECT 1 FROM _wm_scd2_changed \ WHERE _wm_scd2_changed.\"id\" IS NOT DISTINCT FROM _wm_target.dim_scd2.\"id\");" )); // open: INSERT the new current version, null-safe key matching assert!(st[4].starts_with( "INSERT INTO _wm_target.dim_scd2 SELECT s.*, CAST(now() AS TIMESTAMP) AS valid_from" )); assert!(st[4].contains( "true AS is_current FROM (SELECT id, name FROM dl.src) s WHERE EXISTS \ (SELECT 1 FROM _wm_scd2_changed c WHERE c.\"id\" IS NOT DISTINCT FROM s.\"id\");" )); // consumer-convenience `_current` view: `IF NOT EXISTS` (created once, // no-op on unchanged reruns) and INSIDE the txn (folded into the write snapshot) assert_eq!( st[5], "CREATE VIEW IF NOT EXISTS _wm_target.dim_scd2_current AS SELECT * FROM _wm_target.dim_scd2 WHERE is_current;" ); assert_eq!(st[6], "COMMIT;"); // no fragile constructs: no MERGE INTO, and no NULL-dropping `IN (SELECT` assert!(!st.iter().any(|s| s.contains("MERGE INTO"))); assert!(!st.iter().any(|s| s.contains("IN (SELECT"))); // soft-delete default: no deleted-key set, no second close assert!(!st.iter().any(|s| s.contains("_wm_scd2_deleted"))); } #[test] fn codegen_scd2_explicit_track_projects_key_and_tracked_cols() { let cg = MaterializeCodegen { target_qualified: "_wm_target.dim", select_sql: "SELECT id, name, addr FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy: MaterializeStrategy::Scd2 { key: "id".to_string(), track: vec!["name".to_string()], close_deleted: false, }, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); // only key + tracked cols are compared (addr changes don't rotate a version) assert!(st[1].contains("SELECT \"id\", \"name\" FROM (SELECT id, name, addr FROM dl.src) EXCEPT SELECT \"id\", \"name\" FROM _wm_target.dim WHERE is_current")); } #[test] fn codegen_scd2_close_deleted_adds_deleted_set_and_second_close() { let cg = MaterializeCodegen { target_qualified: "_wm_target.dim", select_sql: "SELECT id, name FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy: MaterializeStrategy::Scd2 { key: "id".to_string(), track: vec![], close_deleted: true, }, on_schema_change: OnSchemaChange::Warn, }; let st = cg.statements(); // the deleted-key set: current keys absent from the snapshot, captured // before the transaction (like `changed`) assert!(st.iter().any(|s| s.contains( "CREATE OR REPLACE TEMP TABLE _wm_scd2_deleted AS SELECT \"id\" FROM \ (SELECT \"id\" FROM _wm_target.dim WHERE is_current EXCEPT SELECT \"id\" FROM (SELECT id, name FROM dl.src));" ))); // a second close UPDATE against the deleted set (null-safe), and NO INSERT // that reopens deleted keys (the only INSERT filters on `_wm_scd2_changed`) assert!(st .iter() .any(|s| s.starts_with("UPDATE _wm_target.dim SET valid_to") && s.contains( "EXISTS (SELECT 1 FROM _wm_scd2_deleted \ WHERE _wm_scd2_deleted.\"id\" IS NOT DISTINCT FROM _wm_target.dim.\"id\");" ))); assert_eq!( st.iter().filter(|s| s.starts_with("INSERT INTO")).count(), 1 ); assert!(st .iter() .find(|s| s.starts_with("INSERT INTO")) .unwrap() .contains("_wm_scd2_changed")); // the deleted close is inside the transaction (between BEGIN and COMMIT) let begin = st.iter().position(|s| s == "BEGIN TRANSACTION;").unwrap(); let commit = st.iter().position(|s| s == "COMMIT;").unwrap(); let del_close = st .iter() .position(|s| s.starts_with("UPDATE") && s.contains("_wm_scd2_deleted")) .unwrap(); assert!(begin < del_close && del_close < commit); } fn persist_cg(strategy: MaterializeStrategy, osc: OnSchemaChange) -> Vec { MaterializeCodegen { target_qualified: "_wm_target.t", select_sql: "SELECT a, c FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy, on_schema_change: osc, } .statements() } #[test] fn codegen_fail_emits_drift_guard_inside_txn_before_write() { let st = persist_cg( MaterializeStrategy::Merge { unique_key: "a".into() }, OnSchemaChange::Fail, ); let begin = st.iter().position(|s| s == "BEGIN TRANSACTION;").unwrap(); let guard = st .iter() .position(|s| s.contains("error(") && s.contains("on_schema_change=fail")) .expect("fail emits a guard"); let del = st .iter() .position(|s| s.starts_with("DELETE FROM")) .unwrap(); let insert = st .iter() .position(|s| s.starts_with("INSERT INTO")) .unwrap(); let commit = st.iter().position(|s| s == "COMMIT;").unwrap(); // guard runs right after BEGIN and before any mutation, inside the txn assert!(begin < guard && guard < del && del < insert && insert < commit); // both CASE branches are VARCHAR so the planner can't fold error() away assert!(st[guard].contains("CAST(error(")); assert!(st[guard].contains("ELSE 'ok' END")); // fail is positional (no BY NAME) and emits no sync sentinel assert!(st[insert].starts_with("INSERT INTO _wm_target.t SELECT")); assert!(!st.iter().any(|s| s == SYNC_ALTER_SENTINEL)); } #[test] fn fail_guard_drift_is_name_set_based_not_ordered() { // Documents a deliberate boundary: the fail guard fires on the column // SET difference (EXCEPT over column_name), so a pure reorder of // same-named columns is NOT caught here — that is `sync`'s job (BY NAME). // Keeping this name-set (not an ordered comparison) is what prevents the // guard from false-positive-aborting a correctly-aligned write. If this // ever moves to an ordered comparison, update the OnSchemaChange doc. let guard = schema_drift_guard_sql("SELECT b, a FROM dl.src", "_wm_target.t", "_wm_partition"); assert!(guard.contains("column_name")); assert!(guard.contains("EXCEPT")); // No positional/ordinal comparison in the guard condition. assert!(!guard.to_lowercase().contains("ordinal")); assert!(!guard.to_lowercase().contains("row_number")); } #[test] fn codegen_sync_uses_by_name_and_sentinel() { let st = persist_cg(MaterializeStrategy::Append, OnSchemaChange::Sync); let begin = st.iter().position(|s| s == "BEGIN TRANSACTION;").unwrap(); let sentinel = st.iter().position(|s| s == SYNC_ALTER_SENTINEL).unwrap(); let insert = st .iter() .position(|s| s.starts_with("INSERT INTO")) .unwrap(); // the ALTER-injection slot is right after BEGIN, before the write assert!(begin < sentinel && sentinel < insert); // name-mapped insert (a positional insert would cross-wire ALTERed cols) assert!(st[insert].starts_with("INSERT INTO _wm_target.t BY NAME SELECT")); assert!(!st.iter().any(|s| s.contains("error("))); } #[test] fn codegen_ignore_and_warn_write_positionally_no_guard() { for osc in [OnSchemaChange::Ignore, OnSchemaChange::Warn] { let st = persist_cg(MaterializeStrategy::Append, osc); assert!(!st.iter().any(|s| s.contains("error(")), "{osc:?}"); assert!(!st.iter().any(|s| s == SYNC_ALTER_SENTINEL), "{osc:?}"); let insert = st.iter().find(|s| s.starts_with("INSERT INTO")).unwrap(); assert!( insert.starts_with("INSERT INTO _wm_target.t SELECT"), "{osc:?}" ); } } #[test] fn codegen_whole_table_replace_and_scd2_ignore_guardrail() { // Whole-table replace (unpartitioned) and scd2 are not persist-and-mutate: // fail/sync must not add a guard/sentinel/BY NAME there. let repl = MaterializeCodegen { target_qualified: "_wm_target.t", select_sql: "SELECT a FROM dl.src", partition_col: "_wm_partition", partition_value_sql: "''", partitioned: false, strategy: MaterializeStrategy::Replace, on_schema_change: OnSchemaChange::Fail, }; assert!(!repl.is_persist_and_mutate()); let st = repl.statements(); assert!(!st .iter() .any(|s| s.contains("error(") || s == SYNC_ALTER_SENTINEL)); let scd2 = MaterializeCodegen { strategy: MaterializeStrategy::Scd2 { key: "a".into(), track: vec![], close_deleted: false, }, on_schema_change: OnSchemaChange::Sync, ..repl }; assert!(!scd2.is_persist_and_mutate()); let st = scd2.statements(); assert!(!st.iter().any(|s| s == SYNC_ALTER_SENTINEL)); assert!(!st.iter().any(|s| s.contains(" BY NAME "))); } #[test] fn summary_schema_drift_field_only_for_warn_persist_and_mutate() { // warn + persist-and-mutate ⇒ summary carries the drift column let warn = plan_for_osc(MaterializeStrategy::Append, false, OnSchemaChange::Warn); assert!(warn.stmts.last().unwrap().sql.contains("AS schema_drift")); // ignore / fail / sync ⇒ no summary drift column (they guard the write) for osc in [ OnSchemaChange::Ignore, OnSchemaChange::Fail, OnSchemaChange::Sync, ] { let p = plan_for_osc(MaterializeStrategy::Append, false, osc); assert!( !p.stmts.last().unwrap().sql.contains("schema_drift"), "{osc:?} must not emit the summary drift column" ); } // whole-table replace + warn ⇒ not persist-and-mutate ⇒ no drift column let repl = plan_for_osc(MaterializeStrategy::Replace, false, OnSchemaChange::Warn); assert!(!repl.stmts.last().unwrap().sql.contains("schema_drift")); } #[test] fn fail_guard_is_write_kind_between_txn_markers() { use MaterializeStmtKind::*; let plan = plan_for_osc( MaterializeStrategy::Merge { unique_key: "a".into() }, false, OnSchemaChange::Fail, ); let begin = kidx(&plan, |s| s.kind == TxnBegin); let commit = kidx(&plan, |s| s.kind == TxnCommit); let guard = kidx(&plan, |s| { s.sql.contains("error(") && s.sql.contains("on_schema_change=fail") }); assert_eq!(plan.stmts[guard].kind, Write); assert!(begin < guard && guard < commit); } #[test] fn snapshot_capture_targets_alias() { assert_eq!( snapshot_capture_sql("_wm_target"), "SELECT max(snapshot_id) AS snapshot_id FROM ducklake_snapshots('_wm_target');" ); } #[test] fn build_wrap_blocks_orders_setup_attach_codegen_snapshot() { let plan = ok("ATTACH 'ducklake://main' AS dl;\n SELECT a FROM dl.orders WHERE d = '{p}'"); let blocks: Vec = build_wrap_blocks( &plan, "ATTACH 'ducklake:postgres:…' AS _wm_target (DATA_PATH 's3://b/p');", "orders_daily", "main/orders_daily", "_wm_partition", "'2026-06-19'", true, MaterializeStrategy::Replace, OnSchemaChange::Warn, &[], ) .unwrap() .stmts .into_iter() .map(|s| s.sql) .collect(); // setup block first, then the target ATTACH, then codegen, then result. assert!(blocks[0].starts_with("ATTACH 'ducklake://main' AS dl")); // every setup block must be `;`-terminated so re-splitting can't merge it // with the synthetic target ATTACH that follows. assert!(blocks[0].ends_with(';')); assert_eq!( blocks[1], "ATTACH 'ducklake:postgres:…' AS _wm_target (DATA_PATH 's3://b/p');" ); assert!(blocks.iter().any(|b| b.contains("_wm_target.orders_daily"))); assert!(blocks.iter().any(|b| b.starts_with( "DELETE FROM _wm_target.orders_daily WHERE _wm_partition = '2026-06-19'" ))); // the trailing block is the one-row summary (asset / rows / snapshot_id), // partition-scoped for the row count let last = blocks.last().unwrap(); assert!(last.contains("'ducklake://main/orders_daily' AS materialized")); assert!(last.contains("'2026-06-19' AS partition")); assert!(last.contains("WHERE _wm_partition = '2026-06-19') AS rows")); assert!(last.contains("ducklake_snapshots('_wm_target')")); } // -- materialize plan structure ------------------------------------------ fn plan_for(strategy: MaterializeStrategy, partitioned: bool) -> MaterializePlan { plan_for_osc(strategy, partitioned, OnSchemaChange::Warn) } fn plan_for_osc( strategy: MaterializeStrategy, partitioned: bool, on_schema_change: OnSchemaChange, ) -> MaterializePlan { let plan = ok("SELECT a, b FROM src"); build_wrap_blocks( &plan, "ATTACH 'ducklake:…' AS _wm_target;", "orders", "main/orders", "_wm_partition", "'2026-06-19'", partitioned, strategy, on_schema_change, &[ DataTestResolved::BuiltIn(DataTest::NotNull { column: "a".into() }), DataTestResolved::BuiltIn(DataTest::Unique { column: "b".into() }), ], ) .unwrap() } fn kidx(plan: &MaterializePlan, pred: impl Fn(&MaterializeStmt) -> bool) -> usize { plan.stmts .iter() .position(|s| pred(s)) .expect("stmt present") } #[test] fn plan_tags_structure_and_carries_checks() { use MaterializeStmtKind::*; let plan = plan_for(MaterializeStrategy::Replace, true); // leading statements are Setup, ending with the target ATTACH assert!(plan.stmts[0].kind == Setup); assert!(plan .stmts .iter() .take_while(|s| s.kind == Setup) .any(|s| s.sql.contains("_wm_target"))); // txn markers are tagged, everything between them is Write let begin = kidx(&plan, |s| s.kind == TxnBegin); let commit = kidx(&plan, |s| s.kind == TxnCommit); assert!(begin < commit); assert!(plan.stmts[begin + 1..commit] .iter() .all(|s| s.kind == Write)); // bootstrap DDL is Write work (it targets the table, not the session) let bootstrap = kidx(&plan, |s| s.sql.starts_with("CREATE TABLE IF NOT EXISTS")); assert_eq!(plan.stmts[bootstrap].kind, Write); // summary is last and carries the breakdown; checks ride along let last = plan.stmts.last().unwrap(); assert_eq!(last.kind, Summary); assert!(last.sql.contains("AS data_tests")); assert_eq!(plan.checks.len(), 2); assert!(plan.checks[0].name.contains("not_null(a)")); } #[test] fn plan_whole_table_replace_has_no_txn_markers() { use MaterializeStmtKind::*; let plan = plan_for(MaterializeStrategy::Replace, false); assert!(!plan.stmts.iter().any(|s| s.kind == TxnBegin)); assert!(!plan.stmts.iter().any(|s| s.kind == TxnCommit)); assert_eq!( plan.stmts.iter().filter(|s| s.kind == Write).count(), 1, "single atomic CREATE OR REPLACE" ); } #[test] fn plan_scd2_captures_are_write_kind() { use MaterializeStmtKind::*; let plan = plan_for( MaterializeStrategy::Scd2 { key: "a".into(), track: vec![], close_deleted: false }, false, ); let capture = kidx(&plan, |s| s.sql.contains("TEMP TABLE _wm_scd2_changed")); assert_eq!(plan.stmts[capture].kind, Write); // no test declared ⇒ empty checks let plain = ok("SELECT a FROM src"); let no_tests = build_wrap_blocks( &plain, "ATTACH 'ducklake:…' AS _wm_target;", "orders", "main/orders", "_wm_partition", "''", false, MaterializeStrategy::Append, OnSchemaChange::Warn, &[], ) .unwrap(); assert!(no_tests.checks.is_empty()); } // -- data tests --------------------------------------------------------- fn ctx_partitioned() -> DataTestCtx<'static> { DataTestCtx { target_qualified: "_wm_target.orders", asset_path: "analytics/orders", partition_col: "_wm_partition", partition_value_sql: "'2026-06-19'", partitioned: true, scd2: false, } } fn ctx_unpartitioned() -> DataTestCtx<'static> { DataTestCtx { partitioned: false, ..ctx_partitioned() } } fn ctx_scd2() -> DataTestCtx<'static> { DataTestCtx { partitioned: false, scd2: true, ..ctx_partitioned() } } #[test] fn data_test_unique_and_not_null_partition_scoped() { let tests = vec![ DataTestResolved::BuiltIn(DataTest::Unique { column: "order_id".into() }), DataTestResolved::BuiltIn(DataTest::NotNull { column: "user_id".into() }), ]; let sql = build_data_test_checks(&tests, &ctx_partitioned()).unwrap(); assert!(sql.attaches.is_empty()); assert_eq!(sql.checks.len(), 2); // short, asset-free names (the asset is shown once by the breakdown). assert_eq!(sql.checks[0].name, "unique(order_id)"); assert_eq!(sql.checks[1].name, "not_null(user_id)"); // each probe counts and samples the violating rows in one scan, with // the size guard on the serialized sample. for c in &sql.checks { assert!(c .probe .starts_with("SELECT v, CASE WHEN strlen(s_raw) <= 51200 THEN s_raw END AS s")); assert!(c.probe.contains("count(*) AS v")); assert!(c.probe.contains("FILTER (WHERE _wm_rn <= 20)")); } // unique: groups non-null keys within the slice, having count>1; the // sample is `{value, count}` pairs at the same grain as the count. assert!(sql.checks[0] .probe .contains("GROUP BY \"order_id\" HAVING count(*) > 1")); assert!(sql.checks[0] .probe .contains("SELECT \"order_id\" AS \"value\", count(*) AS \"count\"")); assert!(sql.checks[0] .probe .contains("\"order_id\" IS NOT NULL AND \"_wm_partition\" = '2026-06-19'")); // not_null: null rows in the slice; the sample excludes the synthetic // partition column (storage detail, not producer output). assert!(sql.checks[1] .probe .contains("WHERE \"user_id\" IS NULL AND \"_wm_partition\" = '2026-06-19'")); assert!(sql.checks[1] .probe .contains("SELECT * EXCLUDE (\"_wm_partition\") FROM")); } #[test] fn data_test_unpartitioned_has_no_partition_scope() { let tests = vec![DataTestResolved::BuiltIn(DataTest::NotNull { column: "id".into(), })]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); assert!(sql.checks[0].probe.contains("WHERE \"id\" IS NULL")); assert!(!sql.checks[0].probe.contains("_wm_partition")); assert!(!sql.checks[0].probe.contains("EXCLUDE")); } #[test] fn data_test_scd2_scopes_builtins_to_current_rows() { // On an SCD2 history table the natural key repeats across closed // versions, so built-in probes must assert the current snapshot only. let tests = vec![ DataTestResolved::BuiltIn(DataTest::Unique { column: "customer_id".into() }), DataTestResolved::BuiltIn(DataTest::NotNull { column: "tier".into() }), DataTestResolved::BuiltIn(DataTest::AcceptedValues { column: "region".into(), values: vec!["emea".into()], }), ]; let sql = build_data_test_checks(&tests, &ctx_scd2()).unwrap(); assert!(sql.checks[0] .probe .contains("WHERE \"customer_id\" IS NOT NULL AND is_current")); assert!(sql.checks[1] .probe .contains("WHERE \"tier\" IS NULL AND is_current")); assert!(sql.checks[2].probe.contains("AND is_current")); // no partition scope leaks in (scd2 is unpartitioned in v1) assert!(!sql.checks[0].probe.contains("_wm_partition")); } #[test] fn data_test_accepted_values_escapes_literals() { let tests = vec![DataTestResolved::BuiltIn(DataTest::AcceptedValues { column: "status".into(), values: vec!["paid".into(), "o'brien".into()], })]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); assert!(sql.checks[0].probe.contains("NOT IN ('paid', 'o''brien')")); assert!(sql.checks[0].probe.contains("\"status\" IS NOT NULL")); } #[test] fn data_test_relationships_attaches_ref_and_dedups() { let tests = vec![ DataTestResolved::BuiltIn(DataTest::Relationships { column: "user_id".into(), to_kind: AssetKind::DataTable, to_path: "prod/users".into(), to_column: "id".into(), }), // second relationship into the SAME db reuses the alias (no 2nd attach) DataTestResolved::BuiltIn(DataTest::Relationships { column: "buyer_id".into(), to_kind: AssetKind::DataTable, to_path: "prod/buyers".into(), to_column: "id".into(), }), ]; let sql = build_data_test_checks(&tests, &ctx_partitioned()).unwrap(); assert_eq!(sql.attaches.len(), 1, "same db attached once"); assert_eq!(sql.attaches[0], "ATTACH 'datatable://prod' AS _wm_ref_0;"); assert!(sql.checks[0] .probe .contains("NOT EXISTS (SELECT 1 FROM _wm_ref_0.\"users\"")); assert!(sql.checks[1] .probe .contains("NOT EXISTS (SELECT 1 FROM _wm_ref_0.\"buyers\"")); assert!(sql.checks[0] .probe .contains("_wm_src.\"_wm_partition\" = '2026-06-19'")); // sample rows come from the aliased target and drop the synthetic // partition column. assert!(sql.checks[0] .probe .contains("SELECT _wm_src.* EXCLUDE (\"_wm_partition\") FROM")); assert_eq!( sql.checks[0].name, "relationships(user_id -> prod/users.id)" ); } #[test] fn data_test_relationships_escapes_ref_name_in_attach() { let tests = vec![DataTestResolved::BuiltIn(DataTest::Relationships { column: "k".into(), to_kind: AssetKind::DataTable, to_path: "ev'il/users".into(), to_column: "id".into(), })]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); // single quote in the name is doubled so it can't break out of the literal assert_eq!(sql.attaches[0], "ATTACH 'datatable://ev''il' AS _wm_ref_0;"); } #[test] fn data_test_relationships_same_lake_reuses_target() { // A relationship into the SAME ducklake as the materialize target // (asset_path = "analytics/orders") must NOT re-ATTACH it — _wm_target // already holds that catalog; reuse it. let tests = vec![DataTestResolved::BuiltIn(DataTest::Relationships { column: "user_id".into(), to_kind: AssetKind::Ducklake, to_path: "analytics/users".into(), to_column: "id".into(), })]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); assert!( sql.attaches.is_empty(), "same-lake ref must not ATTACH again" ); assert!(sql.checks[0] .probe .contains("NOT EXISTS (SELECT 1 FROM _wm_target.\"users\"")); } #[test] fn data_test_relationships_schema_qualified_target() { // `/.
` — the schema-qualified table must quote each // segment so the dot stays a separator, not part of one identifier. let tests = vec![DataTestResolved::BuiltIn(DataTest::Relationships { column: "sku".into(), to_kind: AssetKind::Ducklake, to_path: "warehouse/main.dim_products".into(), to_column: "sku".into(), })]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); assert_eq!( sql.attaches[0], "ATTACH 'ducklake://warehouse' AS _wm_ref_0;" ); assert!( sql.checks[0] .probe .contains("FROM _wm_ref_0.\"main\".\"dim_products\""), "schema-qualified target should be quoted per segment: {}", sql.checks[0].probe ); } #[test] fn data_test_relationships_rejects_non_attachable_kind() { let tests = vec![DataTestResolved::BuiltIn(DataTest::Relationships { column: "k".into(), to_kind: AssetKind::S3Object, to_path: "bucket/file".into(), to_column: "c".into(), })]; assert!(build_data_test_checks(&tests, &ctx_unpartitioned()).is_err()); } #[test] fn data_test_custom_wraps_body() { let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: "SELECT * FROM _wm_target.orders WHERE amount < 0;".into(), }]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); // trailing ; stripped, body embedded as the probe's rows query assert!(sql.checks[0] .probe .contains("FROM (SELECT * FROM _wm_target.orders WHERE amount < 0) _wm_v")); assert!(sql.checks[0].probe.contains("count(*) AS v")); assert_eq!(sql.checks[0].name, "custom(f/tests/amount)"); } #[test] fn data_test_custom_rejects_multi_statement_body() { // The body is embedded as a subquery, so a setup-then-SELECT body would // produce invalid SQL — reject it up front with a self-teaching error // that names the violation and shows the correct single-SELECT shape. let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: "SET threads = 1; SELECT * FROM _wm_target.orders WHERE amount < 0".into(), }]; let err = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap_err(); assert!(err.contains("single SELECT"), "unexpected error: {err}"); assert!( err.contains("found 2 statements"), "unexpected error: {err}" ); // the copyable example points at the internal target alias. assert!( err.contains("SELECT * FROM _wm_target.orders WHERE "), "unexpected error: {err}" ); } #[test] fn data_test_custom_rejects_non_select_body() { // A write/DDL body can't be embedded as `FROM ()`; the error must // say so and teach the single-SELECT convention. let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: "DELETE FROM _wm_target.orders WHERE amount < 0".into(), }]; let err = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap_err(); assert!(err.contains("single SELECT"), "unexpected error: {err}"); assert!( err.contains("SELECT * FROM _wm_target.orders WHERE "), "unexpected error: {err}" ); } #[test] fn data_test_custom_rejects_wrong_target_alias() { // Referencing the target by its bare table name (not `_wm_target.
`) // is the most common custom-test mistake — the runtime only attaches the // freshly-materialized target under `_wm_target`, so the query would fail // at runtime. Catch it at codegen with a self-teaching error. let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: "SELECT * FROM orders WHERE amount < 0".into(), }]; let err = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap_err(); assert!(err.contains("_wm_target"), "unexpected error: {err}"); assert!( err.contains("SELECT * FROM _wm_target.orders WHERE "), "unexpected error: {err}" ); } #[test] fn data_test_custom_accepts_from_first_and_uppercased_alias() { // DuckDB's FROM-first syntax is a valid Output, and the alias match is // case-insensitive (SQL identifiers are), so this passes. let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: "FROM _WM_TARGET.orders WHERE amount < 0".into(), }]; let sql = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap(); assert!(sql.checks[0] .probe .contains("FROM (FROM _WM_TARGET.orders WHERE amount < 0) _wm_v")); } #[test] fn data_test_custom_empty_body_teaches_shape() { let tests = vec![DataTestResolved::Custom { path: "f/tests/amount".into(), body: " \n-- just a comment\n".into(), }]; let err = build_data_test_checks(&tests, &ctx_unpartitioned()).unwrap_err(); assert!(err.contains("empty test body"), "unexpected error: {err}"); assert!( err.contains("SELECT * FROM _wm_target.orders WHERE "), "unexpected error: {err}" ); } #[test] fn data_test_unresolved_custom_is_internal_error() { let tests = vec![DataTestResolved::BuiltIn(DataTest::Custom { path: "f/x".into(), })]; assert!(build_data_test_checks(&tests, &ctx_unpartitioned()).is_err()); } #[test] fn materialize_result_sql_embeds_data_tests_breakdown() { let checks = vec![ DataTestCheck { name: "unique(order_id)".into(), probe: "SELECT v, s FROM q0".into() }, DataTestCheck { name: "custom(f/t)".into(), probe: "SELECT v, s FROM q1".into() }, ]; let sql = materialize_result_sql( "_wm_target.orders", "analytics/orders", "_wm_partition", "'2026-06-19'", false, &checks, None, ); // each probe runs once as a one-row CTE; _wm_tr cross-joins them and // the list-of-struct references the flattened count/sample columns. assert!(sql.starts_with( "WITH _wm_t0 AS (SELECT v, s FROM q0), _wm_t1 AS (SELECT v, s FROM q1), \ _wm_tr AS (SELECT _wm_t0.v AS c0, _wm_t0.s AS s0, _wm_t1.v AS c1, _wm_t1.s AS s1 \ FROM _wm_t0, _wm_t1)" )); assert!(sql.contains("[{'test': 'unique(order_id)', 'violating': c0, 'sample': s0}, ")); assert!( sql.contains("{'test': 'custom(f/t)', 'violating': c1, 'sample': s1}] AS data_tests") ); assert!(sql.contains("FROM _wm_tr;")); // no tests -> plain summary, no CTE / data_tests column. let plain = materialize_result_sql( "_wm_target.orders", "analytics/orders", "_wm_partition", "'x'", false, &[], None, ); assert!(plain.starts_with("SELECT 'ducklake://analytics/orders' AS materialized")); assert!(!plain.contains("data_tests")); // Schema capture (gap #2a) is in every summary, tests or not. Unpartitioned // → explicit ordering, no partition-column filter. for s in [&sql, &plain] { assert!(s.contains( "(SELECT list({'name': column_name, 'type': column_type} ORDER BY _wm_ord) \ FROM (SELECT column_name, column_type, row_number() OVER () AS _wm_ord \ FROM (DESCRIBE SELECT * FROM _wm_target.orders))) AS output_schema" )); assert!(!s.contains("WHERE column_name <>")); } } #[test] fn materialize_result_sql_schema_excludes_partition_column() { // Partitioned → the synthetic `_wm_partition` column is filtered out so // the captured schema is the producer's logical output only. let sql = materialize_result_sql( "_wm_target.orders_daily", "analytics/orders_daily", "_wm_partition", "'2026-06-19'", true, &[], None, ); assert!(sql.contains( "FROM (DESCRIBE SELECT * FROM _wm_target.orders_daily) \ WHERE column_name <> '_wm_partition')) AS output_schema" )); } }