Skip to content

Commit 5e309cc

Browse files
authored
feat: return column expression ranges with the node SQL read (#1426)
* wip: carry SQLQuack expression ranges through native analysis Lineage rows keep the output expression's byte range in the cleaned analysis SQL, the analysis cache stores it, shared analyses rebase it onto each member's relation names, and query_expression_ranges maps it back to the model's query SQL. Not yet exposed by the snapshot or UI. * wip: publish column expression ranges with the node SQL read Snapshot model nodes carry each output column's expression range as a byte range of compiled_sql, mapped back through typed SQL macro markers and cursor bounds; the node SQL read returns them. DuckDB e2e passes; the Snowflake DuckShop edge model and DuckDB qualified columns are strict xfails until SQLQuack's strict parsers record projection spans. * wip: keep the person SQL test within the statement limit * wip: keep expression ranges in the snapshot columns document Ranges move out of the node definition into the per-snapshot columns document, so they never change a node hash or record a node change; the node SQL read ignores unknown per-column keys and drops malformed or misplaced ranges. * wip: drop expression ranges at rewritten edges and meet layout rules Map a cleaned range edge only between source-adjacent characters, so a range beside a removed reference call or inside generated text is dropped instead of moved. Skip unnamed output columns. Split helpers into single-function entry modules, make the tests table-driven, and add a DuckShop edge model qualified by its reference call. * wip: capture the expression-span edge expectations on Snowflake * wip: expect expression ranges in the person journey's node SQL read
1 parent 41db5f8 commit 5e309cc

45 files changed

Lines changed: 1086 additions & 93 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.

‎benchmarks/duckshop/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ project and does not change it:
124124
`edge_running_peaks`, that a pre-hook of `int_edge_basket_running_peaks` creates
125125
(`models/intermediate/edge_cases/_sqlbuild/_hooks/sql/`) and SQLBuild does not declare.
126126

127-
It has 164 models (13 staging, 137 intermediate, 14 marts), 5 Python functions, a SQL table
128-
function, a hook-created JavaScript function, 189 unit tests and 4 scenarios, in nine groups:
127+
It has 166 models (13 staging, 139 intermediate, 14 marts), 5 Python functions, a SQL table
128+
function, a hook-created JavaScript function, 191 unit tests and 4 scenarios, in nine groups:
129129

130130
| Group | Covers |
131131
|-------|--------|
@@ -134,7 +134,7 @@ function, a hook-created JavaScript function, 189 unit tests and 4 scenarios, in
134134
| VARIANT comparisons | `ARRAY_MAX`, `ARRAY_MIN` and `ARRAY_REVERSE` over arrays mixing numbers, strings, booleans, JSON nulls and integers past 2^53; `MAX` across kinds and non-ASCII strings; `=` and `EQUAL_NULL` for `1`, `"1"` and `1.0`, reordered keys, JSON null and DATE or TIMESTAMP values held in a `VARIANT` against their text; `MIN_BY` and `MAX_BY` of `VARIANT` values; `PARTITION BY`, `SELECT DISTINCT` and `ARRAY_AGG(DISTINCT)` over `VARIANT`; JSON nulls in `ARRAY_REMOVE`, `ARRAY_DISTINCT` and `ARRAYS_OVERLAP`; decimals with trailing zeros in `ARRAY_CONTAINS` and `ARRAY_POSITION`; `OBJECT_CONSTRUCT(* EXCLUDE ("note"))` |
135135
| Dates and times | `TRY_TO_TIMESTAMP_NTZ` and its siblings over free text; epoch milliseconds as numbers and text; daylight-saving gaps and overlaps; fractional offsets; nanosecond truncation and epoch parts after 2242; pre-1970 text with sub-microsecond digits; date-part normalisation; month ends and leap days; week numbering across a year; sentinel timestamps; `TRY_TO_DATE` with numeric and month-name formats over unpadded fields, two-digit years, ISO `T` and `Z`, trailing letters, a long s, mixed separators and runs of spaces; text read as `TIMESTAMP_TZ` and `TIMESTAMP_LTZ` with `T`, `Z`, `+00:00`, `+0000`, `+09:00` and `-05:30`, back as the UTC instant, date, year and day start, and compared with text, `TIMESTAMP_NTZ` and `DATE`; `FIRST_VALUE` of a zoned value as a `DATE`; `DATEADD` and `TIMEADD` on `TIME(3)` and the type they return; `DATEDIFF` across units; `TIME(9)`, `FF3` and bare `FF` formats truncating; `DATE`, `TO_TIME`, `TRY_TO_TIMESTAMP` and `TO_TIMESTAMP_LTZ` of clock text, and `CURRENT_DATE` and `SYSDATE` read only for facts that hold on any day |
136136
| Ordering, numbers and text | NULL ordering under `DESC`; ties in ordered aggregates, `MAX_BY`, `MODE` and `ANY_VALUE`; default window frames; `UNION ALL BY NAME`; `ROUND` half-way values, decimal division, `MOD` and 38-digit amounts; integers past 2^53 against `FLOAT`; regular expressions and backslash escapes; `LIKE ... ESCAPE` and `ILIKE ANY`; `SPLIT_PART` and `SUBSTR` before the start; `NOT IN` with a NULL; names in many scripts; `JOIN ... USING` between `NUMBER(18,0)`, `NUMBER(38,0)` and text keys, with the types of the merged and qualified keys; NULL arithmetic and the types of NULL, decimal and exponent literals, `COUNT`, `ROW_NUMBER`, `%` and `NULLIF`; `MEDIAN` at scales 0 to 15; `DIV0` by literal zeros; `MODE`, `MEDIAN` and `COUNT_IF` over no rows; aggregates inside window functions and `QUALIFY`; `ROLLUP` totals; `TRUNC` of `FLOAT`; infinity words in other scripts; `CASE` without `ELSE` and `DECODE` over text; regular expressions with a `?` after a lazy quantifier, classes opening with `]`, empty matches and `.` against a carriage return; case-insensitive matches outside ASCII; `ILIKE` with a long s; `JAROWINKLER_SIMILARITY` of accented names in both cases; Base64 text with line feeds; `STARTSWITH`, `ENDSWITH`, `SUBSTRING`, `POSITION` with `IN` and with a comma, `CHARINDEX`, `CHR`, `REGEXP_SUBSTR_ALL`, `::STRING` and `CAST ... AS STRING`; `HEX_ENCODE`, `HEX_DECODE_STRING`, `TRY_HEX_DECODE_STRING` and `TRY_BASE64_DECODE_BINARY`; a comment attached to a text literal; `FLOOR` with and without a scale, `BITAND`, `ZEROIFNULL`, `::BIGINT` and `::DOUBLE`; `IS NOT DISTINCT FROM` and `EQUAL_NULL`; a comma join after an explicit join; `HASH_AGG` read in two row orders |
137-
| Lineage and analysis | clauses whose columns decide rows without being output (`WHERE`, `QUALIFY`, `GROUP BY` by position and `ALL`, `ORDER BY` under `LIMIT`, `EXISTS`, a `FLATTEN` input, `ROLLUP` by position, `EXCEPT` under `LIMIT`); `RIGHT JOIN ... USING`, `NATURAL JOIN`, a `USING` key under a later outer join, and unmatched `ASOF JOIN` rows; `CONNECT BY` beside a column named `level`; a recursive CTE without `RECURSIVE` whose columns shift per iteration; `LAST_DAY` with a date part; positional columns and the names Snowflake gives unaliased expressions; `TEXT(5)` and widened `COUNT` contracts; `LAG` and `LEAD` with one, two and three arguments; a comma-joined `LATERAL` whose nested derived table reads the left item; untyped and typed `FILTER` lambdas over `ARRAY_AGG` in a grouped query |
137+
| Lineage and analysis | clauses whose columns decide rows without being output (`WHERE`, `QUALIFY`, `GROUP BY` by position and `ALL`, `ORDER BY` under `LIMIT`, `EXISTS`, a `FLATTEN` input, `ROLLUP` by position, `EXCEPT` under `LIMIT`); `RIGHT JOIN ... USING`, `NATURAL JOIN`, a `USING` key under a later outer join, and unmatched `ASOF JOIN` rows; `CONNECT BY` beside a column named `level`; a recursive CTE without `RECURSIVE` whose columns shift per iteration; `LAST_DAY` with a date part; positional columns and the names Snowflake gives unaliased expressions; `TEXT(5)` and widened `COUNT` contracts; `LAG` and `LEAD` with one, two and three arguments; a comma-joined `LATERAL` whose nested derived table reads the left item; untyped and typed `FILTER` lambdas over `ARRAY_AGG` in a grouped query; column expression ranges in compiled SQL that SQL and Python macros, a variable and cursor bounds rewrote, and columns qualified by their reference call, whose ranges are dropped |
138138
| Python functions | volatile tokens and numbers from a column, a constant and no argument, and `DIV0` of a volatile call beside look-alike names, checked by assertions that hold for any values; a handler answering NULL input without `STRICT`; Python `None` as JSON null, and datetimes, Decimals and integers past 2^53 handed to `VARIANT` and `FLOAT` results |
139139
| Scenarios and lifecycle | typed values across model boundaries; `VARIANT` expected output; a star under a lower-case contract and a quoted camelCase export; a snapshot keyed and compared on quoted camelCase columns; a cursor-window incremental model over far-apart fixture loads; a change log merged across two loads, checked against a full rebuild |
140140
| Translation refusals | one model per construct that translation tools refuse or guard, most putting a construct refused only under strict checking before one refused by default, so a model shows both: `FLOAT` values through sums, averages, running totals, arrays, objects, `HASH`, `TO_CHAR` and `::NUMBER(38, 10)`; `VARIANT` dates, decimals and XML; `ARRAY_AGG` without `WITHIN GROUP`, sparse arrays and `ARRAY(NUMBER)` casts; `REDUCE`, `FILTER` and `TRANSFORM` lambdas; patterns and `REGEXP_REPLACE` replacements built from columns, and lazy patterns; `TRY_TO_DATE` with a format from a column; zoned literals, `CONVERT_TIMEZONE` from `CURRENT_TIMESTAMP`, `SEQ8`, `GENERATOR` with an expression `ROWCOUNT`; `PIVOT`, `ANY_VALUE`, `IGNORE NULLS` around `IFF`, unaliased columns read by Snowflake's name, `UNION` branches whose column is always NULL; `TO_NUMBER`, `TO_DECIMAL` and `TRY_BASE64_DECODE_BINARY`; an open (uncontracted) source `edge__supplier_notes`, the table function, read for one basket and after `LATERAL` for every basket, and the hook-created function. `rpt_edge_case_model_keys` and `rpt_edge_case_array_model_keys` read every model in the group, so a refusal there also refuses a downstream model |

‎benchmarks/duckshop/edge_cases/orchestration/node_sql.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,14 @@
33
"compiled_sql": "SELECT 23 AS amount -- Orders 🧾\n",
44
"python_source": "def order_amount():\n return 23\n",
55
"function_sql": "43",
6-
"updated_sql": "SELECT 37 AS amount -- Updated orders 🧾\n"
6+
"updated_sql": "SELECT 37 AS amount -- Updated orders 🧾\n",
7+
"columns_document": {
8+
"amount": {"expression_range": [7, 19], "described_as": "a later per-column key readers ignore"},
9+
"past_the_end": {"expression_range": [7, 4096]},
10+
"split_character": {"expression_range": [7, 32]},
11+
"reversed": {"expression_range": [19, 7]},
12+
"no_range": {}
13+
},
14+
"expression_ranges": {"amount": [7, 19]},
15+
"updated_expression_ranges": {"amount": [7, 19], "split_character": [7, 32]}
716
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
MODEL (
2+
description """
3+
One row per edge-case price window loaded in the cursor window, with its price converted by a
4+
typed SQL macro and rounded by a Python macro, the reporting currency from a variable and a
5+
non-ASCII label, so the compiled SQL differs from the authored text before every output column;
6+
the acceptance case for column expression ranges that point into the compiled SQL.
7+
""",
8+
tags [edge_case],
9+
materialized incremental,
10+
incremental_strategy delete_insert,
11+
cursor loaded_at,
12+
cursor_type timestamp,
13+
cursor_grain second,
14+
cursor_inputs (
15+
edge__price_windows _loaded_at,
16+
),
17+
columns (
18+
window_id (type VARCHAR, nullable false, audits [not_null, unique]),
19+
price_amount (type NUMBER(38, 6)),
20+
rounded_price (type NUMBER(18, 2)),
21+
currency (type VARCHAR),
22+
label (type VARCHAR),
23+
loaded_at (type TIMESTAMP_NTZ, nullable false),
24+
),
25+
);
26+
27+
SELECT
28+
w.window_id,
29+
@sql.money_from_minor(w.price_minor, 2) AS price_amount,
30+
@py.round_half_even("w.price_minor / 100", 2)::NUMBER(18, 2) AS rounded_price,
31+
'@@reporting_currency'::VARCHAR AS currency,
32+
'Prix à jour €'::VARCHAR AS label,
33+
w._loaded_at AS loaded_at
34+
FROM __source("edge__price_windows") AS w
35+
WHERE
36+
w._loaded_at >= __cursor_start()
37+
AND w._loaded_at < __cursor_end()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
MODEL (
2+
description """
3+
One row per edge-case price window, each column qualified by the source reference call itself,
4+
so every output column's expression starts at text the compiler rewrites; the case for column
5+
expression ranges that must be dropped rather than moved onto the relation name.
6+
""",
7+
tags [edge_case],
8+
columns (
9+
window_id (type VARCHAR, nullable false, audits [not_null, unique]),
10+
sku (type VARCHAR),
11+
),
12+
);
13+
14+
SELECT
15+
__source("edge__price_windows").window_id,
16+
__source("edge__price_windows").sku
17+
FROM __source("edge__price_windows")

‎benchmarks/duckshop/project/sqlbuild_project.toml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,18 @@ The edge case reads the line through a derived table nested in a comma-joined LA
337337
that shape is the case under test.
338338
"""
339339

340+
[[rules.rule_ignores]]
341+
rules = ["SQLCLEAN034"]
342+
paths = [
343+
"models/intermediate/edge_cases/int_edge_expression_spans.sql",
344+
"models/intermediate/edge_cases/int_edge_reference_qualified_spans.sql",
345+
]
346+
reason = """
347+
These edge cases keep their macro calls, variable, literals and reference calls in the terminal
348+
select on purpose: column expression ranges point at the terminal select's items, so that is where
349+
the compiled SQL must differ from the authored text.
350+
"""
351+
340352
[[rules.rule_ignores]]
341353
rules = ["SQLCLEAN040"]
342354
paths = ["models/intermediate/edge_cases/int_edge_amount_bit_reads.sql"]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
TEST (
2+
name "int_edge_expression_spans__converts_prices_loaded_in_the_window",
3+
cursor_start "2026-03-02 00:00:00",
4+
cursor_end "2026-03-03 00:00:00",
5+
);
6+
7+
WITH __source__edge__price_windows AS (
8+
SELECT
9+
v.window_id,
10+
v.sku,
11+
v.price_minor::NUMBER(38, 0) AS price_minor,
12+
v.valid_from::TIMESTAMP_NTZ(6) AS valid_from,
13+
v.valid_to::TIMESTAMP_NTZ(6) AS valid_to,
14+
v._loaded_at::TIMESTAMP_NTZ AS _loaded_at
15+
FROM VALUES
16+
('PW-01', 'SKU-1', 1234, '2026-01-01 00:00:00', '9999-12-31 00:00:00', '2026-03-02 09:00:00'),
17+
('PW-02', 'SKU-2', NULL, '2026-01-01 00:00:00', '9999-12-31 00:00:00', '2026-03-02 18:30:00'),
18+
('PW-03', 'SKU-3', 999, '2026-01-01 00:00:00', '9999-12-31 00:00:00', '2026-03-01 23:59:59') AS v(window_id, sku, price_minor, valid_from, valid_to, _loaded_at)
19+
),
20+
21+
__expected__int_edge_expression_spans AS (
22+
SELECT
23+
'PW-01' AS window_id,
24+
12.340000::NUMBER(38,6) AS price_amount,
25+
12.34::NUMBER(18,2) AS rounded_price,
26+
'USD' AS currency,
27+
'Prix à jour €' AS label,
28+
'2026-03-02 09:00:00'::TIMESTAMP_NTZ(9) AS loaded_at
29+
UNION ALL
30+
SELECT
31+
'PW-02' AS window_id,
32+
CAST(NULL AS NUMBER(38,6)) AS price_amount,
33+
CAST(NULL AS NUMBER(18,2)) AS rounded_price,
34+
'USD' AS currency,
35+
'Prix à jour €' AS label,
36+
'2026-03-02 18:30:00'::TIMESTAMP_NTZ(9) AS loaded_at
37+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
TEST (
2+
name "int_edge_reference_qualified_spans__keeps_every_window",
3+
);
4+
5+
WITH __source__edge__price_windows AS (
6+
SELECT
7+
v.window_id,
8+
v.sku,
9+
v.price_minor::NUMBER(38, 0) AS price_minor,
10+
v.valid_from::TIMESTAMP_NTZ(6) AS valid_from,
11+
v.valid_to::TIMESTAMP_NTZ(6) AS valid_to,
12+
v._loaded_at::TIMESTAMP_NTZ AS _loaded_at
13+
FROM VALUES
14+
('PW-01', 'SKU-1', 1234, '2026-01-01 00:00:00', '9999-12-31 00:00:00', '2026-03-02 09:00:00'),
15+
('PW-02', NULL, NULL, '2026-01-01 00:00:00', '9999-12-31 00:00:00', '2026-03-02 18:30:00') AS v(window_id, sku, price_minor, valid_from, valid_to, _loaded_at)
16+
),
17+
18+
__expected__int_edge_reference_qualified_spans AS (
19+
SELECT
20+
'PW-01' AS window_id,
21+
'SKU-1' AS sku
22+
UNION ALL
23+
SELECT
24+
'PW-02' AS window_id,
25+
CAST(NULL AS VARCHAR) AS sku
26+
)

‎clients/typescript/src/schema.d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,6 +2009,10 @@ export interface components {
20092009
/** @description SQL inspection never returns Python source, execution artifacts or unrelated node metadata. */
20102010
NodeSql: {
20112011
compiled_sql?: string | null;
2012+
/** @description Each placed column's defining expression as a `[start, end)` byte range of `compiled_sql`. */
2013+
expression_ranges?: {
2014+
[key: string]: number[];
2015+
};
20122016
node_key: string;
20132017
raw_sql?: string | null;
20142018
/** Format: uuid */

‎crates/sqlbuild-analysis/src/analysis_route/_helpers/analysis/native_analysis.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ fn lineage_row(
151151
transform_code,
152152
confidence_code,
153153
sources,
154+
expression_range: output
155+
.expression_range
156+
.as_ref()
157+
.map(|range| (range.start, range.end)),
154158
}
155159
}
156160

‎crates/sqlbuild-analysis/src/analysis_route/_helpers/analysis/native_sharing.rs‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ pub(crate) fn analysed_members(
110110
.get(leader)
111111
.filter(|template| exact_for_others(template, members[*leader].model));
112112
match template.map(|template| mapped(template, &members[index].model.lineage_references)) {
113-
Some(Ok(analysis)) => {
113+
Some(Ok(mut analysis)) => {
114+
rebase_ranges(
115+
&mut analysis,
116+
members[*leader].cleaned.as_deref().unwrap_or_default(),
117+
members[index].cleaned.as_deref().unwrap_or_default(),
118+
);
114119
analyses[index] = Some(Ok(analysis));
115120
counts.shared += 1;
116121
counts.unshared -= 1;
@@ -208,6 +213,67 @@ fn mapped(
208213
Ok(analysis)
209214
}
210215

216+
/// Each expression range moved onto `member`'s relation names, or dropped where it cannot move.
217+
pub(crate) fn rebase_ranges(analysis: &mut NativeAnalysis, leader: &str, member: &str) {
218+
if leader == member {
219+
return;
220+
}
221+
let renamed: Option<Vec<(usize, usize, usize)>> = renamed_runs(leader, member);
222+
for row in &mut analysis.lineage {
223+
row.expression_range = match (&renamed, row.expression_range) {
224+
(Some(runs), Some((start, end))) => rebased(runs, start).zip(rebased(runs, end)),
225+
_ => None,
226+
};
227+
}
228+
}
229+
230+
/// `(leader start, leader end, member end)` of each differing identifier run, if only those differ.
231+
fn renamed_runs(leader: &str, member: &str) -> Option<Vec<(usize, usize, usize)>> {
232+
let identifier = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
233+
let (left, right): (&[u8], &[u8]) = (leader.as_bytes(), member.as_bytes());
234+
let (mut i, mut j): (usize, usize) = (0, 0);
235+
let mut runs: Vec<(usize, usize, usize)> = Vec::new();
236+
while i < left.len() || j < right.len() {
237+
if i < left.len() && j < right.len() && left[i] == right[j] {
238+
i += 1;
239+
j += 1;
240+
continue;
241+
}
242+
let mut back: usize = 0;
243+
while back < i && identifier(left[i - back - 1]) {
244+
back += 1;
245+
}
246+
let (mut left_end, mut right_end): (usize, usize) = (i, j);
247+
while left_end < left.len() && identifier(left[left_end]) {
248+
left_end += 1;
249+
}
250+
while right_end < right.len() && identifier(right[right_end]) {
251+
right_end += 1;
252+
}
253+
if left_end == i && right_end == j {
254+
return None;
255+
}
256+
runs.push((i - back, left_end, right_end));
257+
(i, j) = (left_end, right_end);
258+
}
259+
Some(runs)
260+
}
261+
262+
/// The member offset of leader `offset`, or `None` strictly inside a renamed run.
263+
fn rebased(runs: &[(usize, usize, usize)], offset: usize) -> Option<usize> {
264+
let mut moved: usize = offset;
265+
for &(left_start, left_end, right_end) in runs {
266+
if offset <= left_start {
267+
break;
268+
}
269+
if offset < left_end {
270+
return None;
271+
}
272+
moved = offset - left_end + right_end;
273+
}
274+
Some(moved)
275+
}
276+
211277
/// Whether `leader`'s `template` succeeded, found nothing and spells none of its relation names.
212278
fn exact_for_others(template: &Result<NativeAnalysis, String>, leader: &ModelRequest) -> bool {
213279
let Ok(analysis) = template else {

0 commit comments

Comments
 (0)