Skip to content

Commit d616759

Browse files
authored
fix: preserve full-replay history and improve SQL test planning (#801)
* fix(sql-tests): render each upstream model once in the textual SQL-test chain * refactor(sql-tests): make native planner the single SQL-test chain and comparison implementation The Python SQL-test planner and comparison renderer duplicated the native implementation used by compile, and the Python textual chain inlined every upstream model once per dependency path. - Native planning now returns each test's chain and assertion steps, model names and warnings, with optional rendered SQL, in one batch call. - plan/build/test build SqlTestPlanEntry from the native plan; Python keeps fixture validation and plan metadata only. - Comparison SQL, difference samples, compile target files and runtime artifacts all render natively, using the SQL-analysis dialect for CTE lifting. - Each distinct missing mock is reported once per test instead of once per dependency path or chain step. - Textual assertion fallbacks lift their chain CTEs to the top level. - Remove the Python assembly, analysis assembly, comparison helpers and dead wrappers. * fix(sql-tests): omit SQL from chain steps the comparison never renders Native plan output carried every chain step's full upstream closure, so long unmocked chains produced step payloads quadratic in chain length. - Populate resolvedSql, liftedCtes and comparisonBodySql only for steps with an expected CTE or an assertion that reads the step's actual CTE, using one shared rendered-step helper for planner and renderer. - Skip empty steps when building manifest test compiled_code. - Add a long-chain regression test bounding step SQL by the rendered SQL and proving unrendered step SQL cannot change the rendered comparison. * feat(format): accept file paths and explain path selector errors * fix(format): keep leading CTE comments on their own lines above the CTE * feat(lineage): accept several and kind-prefixed targets and expand selections by direction * feat(sql-tests): compare only the columns listed in the expected CTE SQL unit tests compared whole rows by position, so an __expected__ CTE had to list every model column in model order. - The native planner reads each expected CTE's column list syntactically (projection names, VALUES alias lists, first set-operation branch) with the SQL-analysis dialect, keeping authored identifier spelling. It works without SQL analysis and falls back to full-row comparison when the list is not explicit. - Comparison counts and difference samples project both sides to the listed columns; the actual row count still counts every actual row. - With SQL analysis and authoritative model columns, unknown expected columns fail statically before connecting. Otherwise a failed comparison probes the actual step's columns with a zero-row query and reports the missing columns as T005 instead of the warehouse error. - Document the rule in the agent testing skill references. * feat(sql-tests): report missing mocks in sqb test and sqb compile Planner ERROR warnings for a SQL test, such as a reference with no mock, were only visible through `sqb test --inspect`. - `plan_sql_tests` now turns a test's distinct ERROR-severity planning messages into fixture diagnostics, so `sqb test`, `sqb build` and `sqb test --inspect` stop with the existing "Invalid SQL test fixtures" error before execution. WARNING-severity warnings are unchanged. - Compile artifact planning reports the same messages as S000 test-phase compile diagnostics from the single native batch call. They count in the compile summary, appear in --json, and make compile exit 1. Tests with planning errors are not stored in the SQL-test artifact cache, so warm compiles report them again. * fix(incremental): preserve full history when replay replaces a model * fix(lineage): honor exclusions and depth for directional selectors * fix(sql-tests): retain upstream resolution after textual fallback * fix(plan): hide unresolved cursor placeholders in replay ranges * perf(compile): omit unused SQL-test execution plans from artifact responses * test(preview): force a listed-column execution error in failure scenes
1 parent 8818e59 commit d616759

102 files changed

Lines changed: 4705 additions & 3109 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ E2E_DUCKDB_BUILD_INCREMENTAL_PATHS := \
9595
tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_replay_backfill_lifecycle.py \
9696
tests/e2e/src/sqlbuild/cli/commands/main/build/test_mixed_timestamp_grain_replay.py \
9797
tests/e2e/src/sqlbuild/cli/commands/main/build/test_model_backed_cursor_build.py \
98+
tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py \
9899
tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py \
99100
tests/e2e/src/sqlbuild/cli/commands/main/build/test_snapshot_build.py
100101

‎crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,24 @@ fn plan_and_render_sql_tests_json(py: Python<'_>, request_json: &str) -> PyResul
121121
.map_err(value_error)
122122
}
123123

124+
#[pyfunction]
125+
fn resolve_sql_test_chains_json(py: Python<'_>, request_json: &str) -> PyResult<String> {
126+
py.detach(|| {
127+
crate::compiler::main::sql_test_chain_resolution::resolve_chains_json(request_json)
128+
})
129+
.map_err(value_error)
130+
}
131+
132+
#[pyfunction]
133+
fn render_sql_test_difference_sample_json(py: Python<'_>, request_json: &str) -> PyResult<String> {
134+
py.detach(|| {
135+
crate::compiler::main::sql_test_difference_sampling::render_difference_sample_json(
136+
request_json,
137+
)
138+
})
139+
.map_err(value_error)
140+
}
141+
124142
#[pyfunction]
125143
fn extract_sql_tests_json(py: Python<'_>, request_json: &str) -> PyResult<String> {
126144
py.detach(|| crate::compiler::main::sql_test_extraction::extract_batch_json(request_json))
@@ -321,6 +339,11 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
321339
)?)?;
322340
module.add_function(wrap_pyfunction!(render_sql_test_comparisons_json, module)?)?;
323341
module.add_function(wrap_pyfunction!(plan_and_render_sql_tests_json, module)?)?;
342+
module.add_function(wrap_pyfunction!(resolve_sql_test_chains_json, module)?)?;
343+
module.add_function(wrap_pyfunction!(
344+
render_sql_test_difference_sample_json,
345+
module
346+
)?)?;
324347
module.add_function(wrap_pyfunction!(extract_sql_tests_json, module)?)?;
325348
module.add_function(wrap_pyfunction!(parse_model_headers, module)?)?;
326349
module.add_function(wrap_pyfunction!(tokenize_model_header, module)?)?;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! Expected-output column names read syntactically from an `__expected__` CTE body.
2+
3+
use std::collections::HashSet;
4+
5+
use polyglot_sql::expressions::{Identifier, Select};
6+
use polyglot_sql::{Dialect, Expression};
7+
8+
/// Return the authored output column identifiers of `sql`, or `None` when they are not explicit.
9+
pub(crate) fn expected_columns(sql: &str, dialect: &Dialect) -> Option<Vec<String>> {
10+
let mut statements = match dialect.parse(sql) {
11+
Ok(statements) => statements,
12+
Err(_) => return None,
13+
};
14+
if statements.len() != 1 {
15+
return None;
16+
}
17+
let statement = statements.pop()?;
18+
let source = AuthoredSource::new(sql);
19+
let columns: Vec<String> = query_columns(&statement, &source)?;
20+
let mut seen: HashSet<String> = HashSet::new();
21+
if columns.is_empty()
22+
|| !columns
23+
.iter()
24+
.all(|column| seen.insert(normalized_identifier(column)))
25+
{
26+
return None;
27+
}
28+
Some(columns)
29+
}
30+
31+
/// Fold an authored identifier to the case-insensitive name used for column matching.
32+
pub(crate) fn normalized_identifier(identifier: &str) -> String {
33+
let unquoted = match identifier.chars().next() {
34+
Some('"') => identifier.trim_matches('"').replace("\"\"", "\""),
35+
Some('`') => identifier.trim_matches('`').replace("``", "`"),
36+
Some('[') => identifier
37+
.trim_start_matches('[')
38+
.trim_end_matches(']')
39+
.replace("]]", "]"),
40+
_ => identifier.to_string(),
41+
};
42+
unquoted.to_lowercase()
43+
}
44+
45+
struct AuthoredSource<'a> {
46+
sql: &'a str,
47+
char_offsets: Vec<usize>,
48+
}
49+
50+
impl<'a> AuthoredSource<'a> {
51+
fn new(sql: &'a str) -> Self {
52+
let mut char_offsets: Vec<usize> = sql.char_indices().map(|(offset, _)| offset).collect();
53+
char_offsets.push(sql.len());
54+
Self { sql, char_offsets }
55+
}
56+
57+
/// Return the identifier exactly as written, including its quoting.
58+
fn identifier(&self, identifier: &Identifier) -> Option<String> {
59+
let Some(span) = identifier.span.as_ref() else {
60+
return (!identifier.quoted).then(|| identifier.name.clone());
61+
};
62+
let start = *self.char_offsets.get(span.start)?;
63+
let end = *self.char_offsets.get(span.end)?;
64+
let text = self.sql.get(start..end)?;
65+
let consistent = if identifier.quoted {
66+
text.chars().count() >= identifier.name.chars().count() + 2
67+
} else {
68+
text == identifier.name
69+
};
70+
consistent.then(|| text.to_string())
71+
}
72+
}
73+
74+
fn query_columns(expression: &Expression, source: &AuthoredSource<'_>) -> Option<Vec<String>> {
75+
match expression {
76+
Expression::Select(select) => select_columns(select, source),
77+
Expression::Union(union) => query_columns(&union.left, source),
78+
Expression::Intersect(intersect) => query_columns(&intersect.left, source),
79+
Expression::Except(except) => query_columns(&except.left, source),
80+
Expression::Paren(paren) => query_columns(&paren.this, source),
81+
Expression::Subquery(subquery) if subquery.column_aliases.is_empty() => {
82+
query_columns(&subquery.this, source)
83+
}
84+
Expression::Values(values) => identifiers(&values.column_aliases, source),
85+
_ => None,
86+
}
87+
}
88+
89+
fn select_columns(select: &Select, source: &AuthoredSource<'_>) -> Option<Vec<String>> {
90+
if let [Expression::Star(star)] = select.expressions.as_slice() {
91+
if star.table.is_some()
92+
|| star.except.is_some()
93+
|| star.replace.is_some()
94+
|| star.rename.is_some()
95+
|| !select.joins.is_empty()
96+
{
97+
return None;
98+
}
99+
let from = select.from.as_ref()?;
100+
let [relation] = from.expressions.as_slice() else {
101+
return None;
102+
};
103+
return aliased_values_columns(relation, source);
104+
}
105+
select
106+
.expressions
107+
.iter()
108+
.map(|projection| projection_column(projection, source))
109+
.collect()
110+
}
111+
112+
fn aliased_values_columns(
113+
relation: &Expression,
114+
source: &AuthoredSource<'_>,
115+
) -> Option<Vec<String>> {
116+
match relation {
117+
Expression::Subquery(subquery) if matches!(subquery.this, Expression::Values(_)) => {
118+
identifiers(&subquery.column_aliases, source)
119+
}
120+
Expression::Values(values) => identifiers(&values.column_aliases, source),
121+
_ => None,
122+
}
123+
}
124+
125+
fn projection_column(projection: &Expression, source: &AuthoredSource<'_>) -> Option<String> {
126+
match projection {
127+
Expression::Alias(alias) if alias.column_aliases.is_empty() => {
128+
source.identifier(&alias.alias)
129+
}
130+
Expression::Column(column) => source.identifier(&column.name),
131+
_ => None,
132+
}
133+
}
134+
135+
fn identifiers(identifiers: &[Identifier], source: &AuthoredSource<'_>) -> Option<Vec<String>> {
136+
if identifiers.is_empty() {
137+
return None;
138+
}
139+
identifiers
140+
.iter()
141+
.map(|identifier| source.identifier(identifier))
142+
.collect()
143+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub(crate) mod expected_columns;
12
pub(crate) mod extraction;
23
pub(crate) mod planning;
34
pub(crate) mod rendering;

0 commit comments

Comments
 (0)