Files
windmill/backend/parsers/windmill-parser/src/sql_materialize.rs
T
43044c2e28 feat(pipelines): wm_partition macro for grain-agnostic partition filters (#9950)
* wip: partial work before earlyoom-recovery relaunch

* fix(pipelines): scaffold the strftime {partition} filter idiom (frontend-only)

The DuckDB materialize scaffold and the AI pipeline prompt now teach the
grain-agnostic `WHERE strftime(<ts_col>, '<fmt>') = {partition}` filter instead
of the naive `= TIMESTAMP {partition}` cast. `{partition}` substitutes to the
partition IDENTITY string (`2026-07-05T23`, `2026-W27`, `2026-07`), which is not
a valid DuckDB TIMESTAMP literal for any non-daily grain — so the naive form
raises a `Conversion Error` for hourly/weekly/monthly (only daily parses).

Adds a frontend unit test asserting the hourly scaffold emits the strftime
idiom (`%Y-%m-%dT%H`) for every grain and never scaffolds the naive TIMESTAMP
cast as executable SQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pipelines): scope strftime partition idiom to time grains

Review nit: `dynamic` partitioning's identity is a caller-supplied key, not a
timestamp, so `strftime` doesn't apply. Scope the scaffold + AI prompt claim to
time grains and add a `dynamic` example that filters on the user's own key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(pipelines): wm_partition macro for grain-agnostic partition filters

The materialize runtime now injects a `wm_partition(ts)` temp macro as the first
setup statement of a time-partitioned script, so filtering the source to the
active slice is one grain-agnostic line — `WHERE wm_partition(<ts_col>) =
{partition}` — instead of a hand-written `strftime` format the author must keep
in lockstep with the resolver, or the `= TIMESTAMP {partition}` cast that only
parses for daily and Conversion-Errors for hourly/weekly/monthly.

The macro's format comes from `PartitionKind::default_time_format` in
windmill-parser, the same source the EE resolver reads to stamp the `{partition}`
identity, so the two can't drift. `dynamic` partitions get no macro (their
identity is a caller-supplied key → `WHERE <key_col> = {partition}`).

Replaces the earlier 9-line strftime comment block in the scaffold with the
single macro line; AI pipeline prompt and design doc updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pipelines): verify wm_partition strftime parity vs chrono through real DuckDB

Runs the bundled DuckDB engine in-memory and asserts strftime renders every
grain format (daily/hourly/weekly `%G-W%V`/monthly) byte-for-byte identically to
chrono — the engine the resolver uses to stamp the `{partition}` identity —
across ISO-week year boundaries (2027-01-01 → 2026-W53 etc.). Also proves the
injected `wm_partition` macro buckets the whole slice and that the naive
`TIMESTAMP '<weekly|monthly identity>'` cast Conversion-Errors.

Closes the one cross-engine assumption the pure-Rust/frontend tests couldn't
reach (flagged by CI review for weekly ISO-week rendering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 0de2412ff0734b11e12ba378c9bcc373ff9ae800

This commit updates the EE repository reference after PR #649 was merged in windmill-ee-private.

Previous ee-repo-ref: ad6c6685689d7741058e7d2c9ecbe95d982e6268

New ee-repo-ref: 0de2412ff0734b11e12ba378c9bcc373ff9ae800

Automated by sync-ee-ref workflow.

* fix(pipelines): classify CREATE TEMP MACRO as a DuckDB prepare-path setup statement

The FFI prepare/diagnostics pass only EXECUTES statements recognized by
is_setup_statement (ATTACH/USE/INSTALL/…); everything else is merely prepared.
`CREATE [OR REPLACE] TEMP MACRO` wasn't recognized, so on a `-- prepare` run of a
partitioned materialize the injected `wm_partition` macro was never created on
the connection, and the later generated `CREATE TABLE … SELECT … WHERE
wm_partition(...)` failed to bind ("function does not exist"). The same latent
gap affected the workspace-macro splicer, which injects TEMP MACRO blocks too.

Classify CREATE [OR REPLACE] TEMP|TEMPORARY MACRO as setup so it's executed
before dependent blocks and excluded from the PrepareQueryResult count
(persistent CREATE MACRO stays a user statement). Adds a prepare-path test that
fails without the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-06 10:26:53 +02:00

2619 lines
115 KiB
Rust

//! 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<String>,
/// 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<String> {
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<char> = 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<String> {
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<String>| {
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<WrapPlan, WrapError> {
let stmts = split_statements(sql);
if stmts.is_empty() {
return Err(WrapError::Empty);
}
let classes: Vec<BlockClass> = stmts.iter().map(|s| classify_block(s)).collect();
let output_idxs: Vec<usize> = 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::<Vec<_>>().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=<col> 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<String>, 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<String> {
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 `<dim>_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 <key>` — 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<String> {
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 (<scd cols>)` 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::<Vec<_>>()
.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 `<dim>_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 <dim> ON fact.key =
// dim.<key> 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 `<dim>_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<MaterializeStmt>,
pub checks: Vec<DataTestCheck>,
}
/// 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.<table>`. `asset_path` is
/// the full `<name>/<table>` 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<MaterializePlan, String> {
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<MaterializeStmt> = 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 <target>` 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::<Vec<_>>()
.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::<Vec<_>>()
.join(", ");
let tr_from = checks
.iter()
.enumerate()
.map(|(i, _)| format!("_wm_t{i}"))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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(<ts_col>) = {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<String> {
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,
/// `<name>/<table>` 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(<key>)` 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<String>,
pub checks: Vec<DataTestCheck>,
}
/// Alias prefix for a relationships test's referenced asset, attached
/// read-only alongside the target. `_wm_ref_<n>` 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::<Vec<_>>()
.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<String> = 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.<table>` alias — and doubles that alias into a copyable
// one-line example. `target_qualified` is already `_wm_target.<table>`.
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 <condition>` — 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<DataTestChecks, String> {
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::<Vec<_>>()
.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 `<name>/<table>`")
})?;
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 `<lake>/<table>`, 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 (<body>)`), 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 `<dim>_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<String> {
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<String> = 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() {
// `<lake>/<schema>.<table>` — 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 <condition>"),
"unexpected error: {err}"
);
}
#[test]
fn data_test_custom_rejects_non_select_body() {
// A write/DDL body can't be embedded as `FROM (<body>)`; 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 <condition>"),
"unexpected error: {err}"
);
}
#[test]
fn data_test_custom_rejects_wrong_target_alias() {
// Referencing the target by its bare table name (not `_wm_target.<table>`)
// 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 <condition>"),
"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 <condition>"),
"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"
));
}
}