Skip to content

Commit 7bfcee2

Browse files
committed
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.
1 parent 4698f18 commit 7bfcee2

27 files changed

Lines changed: 581 additions & 405 deletions

File tree

‎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 165 models (13 staging, 138 intermediate, 14 marts), 5 Python functions, a SQL table
128-
function, a hook-created JavaScript function, 190 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, 190 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; column expression ranges in compiled SQL that SQL and Python macros, a variable and cursor bounds rewrote |
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 |
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: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,14 @@ that shape is the case under test.
339339

340340
[[rules.rule_ignores]]
341341
rules = ["SQLCLEAN034"]
342-
paths = ["models/intermediate/edge_cases/int_edge_expression_spans.sql"]
342+
paths = [
343+
"models/intermediate/edge_cases/int_edge_expression_spans.sql",
344+
"models/intermediate/edge_cases/int_edge_reference_qualified_spans.sql",
345+
]
343346
reason = """
344-
The edge case keeps its macro calls, variable and literals in the terminal select on purpose:
345-
column expression ranges point at the terminal select's items, so that is where the compiled SQL
346-
must differ from the authored text.
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.
347350
"""
348351

349352
[[rules.rule_ignores]]
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: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,10 +2009,7 @@ 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-
/**
2013-
* @description Each output column's defining expression as a `[start, end)` UTF-8 byte range of
2014-
* `compiled_sql`; absent for columns the analysis could not place.
2015-
*/
2012+
/** @description Each placed column's defining expression as a `[start, end)` byte range of `compiled_sql`. */
20162013
expression_ranges?: {
20172014
[key: string]: number[];
20182015
};

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,12 @@ fn mapped(
213213
Ok(analysis)
214214
}
215215

216-
/// Move each expression range from the leader's cleaned SQL to the member's, which differ only in
217-
/// the relation names they spell; a range that cannot be moved exactly is dropped.
216+
/// Each expression range moved onto `member`'s relation names, or dropped where it cannot move.
218217
pub(crate) fn rebase_ranges(analysis: &mut NativeAnalysis, leader: &str, member: &str) {
219218
if leader == member {
220219
return;
221220
}
222-
let renamed: Option<Vec<(usize, usize, usize, usize)>> = renamed_runs(leader, member);
221+
let renamed: Option<Vec<(usize, usize, usize)>> = renamed_runs(leader, member);
223222
for row in &mut analysis.lineage {
224223
row.expression_range = match (&renamed, row.expression_range) {
225224
(Some(runs), Some((start, end))) => rebased(runs, start).zip(rebased(runs, end)),
@@ -228,13 +227,12 @@ pub(crate) fn rebase_ranges(analysis: &mut NativeAnalysis, leader: &str, member:
228227
}
229228
}
230229

231-
/// `(leader start, leader end, member start, member end)` of each identifier run where two SQL
232-
/// texts differ, when they differ only in whole identifier runs.
233-
fn renamed_runs(leader: &str, member: &str) -> Option<Vec<(usize, usize, usize, usize)>> {
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)>> {
234232
let identifier = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
235233
let (left, right): (&[u8], &[u8]) = (leader.as_bytes(), member.as_bytes());
236234
let (mut i, mut j): (usize, usize) = (0, 0);
237-
let mut runs: Vec<(usize, usize, usize, usize)> = Vec::new();
235+
let mut runs: Vec<(usize, usize, usize)> = Vec::new();
238236
while i < left.len() || j < right.len() {
239237
if i < left.len() && j < right.len() && left[i] == right[j] {
240238
i += 1;
@@ -255,25 +253,25 @@ fn renamed_runs(leader: &str, member: &str) -> Option<Vec<(usize, usize, usize,
255253
if left_end == i && right_end == j {
256254
return None;
257255
}
258-
runs.push((i - back, left_end, j - back, right_end));
256+
runs.push((i - back, left_end, right_end));
259257
(i, j) = (left_end, right_end);
260258
}
261259
Some(runs)
262260
}
263261

264262
/// The member offset of leader `offset`, or `None` strictly inside a renamed run.
265-
fn rebased(runs: &[(usize, usize, usize, usize)], offset: usize) -> Option<usize> {
266-
let mut shift: isize = 0;
267-
for &(left_start, left_end, _, right_end) in runs {
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 {
268266
if offset <= left_start {
269267
break;
270268
}
271269
if offset < left_end {
272270
return None;
273271
}
274-
shift = right_end as isize - left_end as isize;
272+
moved = offset - left_end + right_end;
275273
}
276-
usize::try_from(offset as isize + shift).ok()
274+
Some(moved)
277275
}
278276

279277
/// Whether `leader`'s `template` succeeded, found nothing and spells none of its relation names.

‎crates/sqlbuild-analysis/src/analysis_route/main/query_expression_ranges.rs‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
33
use crate::analysis_route::main::analysis_route::analysis_route;
44
use crate::analysis_route::models::AnalysisRoute;
5-
use crate::semantic_validation::_helpers::normalization::normalize_native_sql_mapped;
5+
use crate::semantic_validation::main::normalize_native_filled::normalize_native_filled;
66
use crate::sql_macros::models::TextSplice;
77

8-
/// Each byte range of `analysis_sql`'s cleaned native analysis SQL as the byte range of the query
9-
/// SQL that `passes` rewrote, in order, into `analysis_sql`; `None` where there was none, where an
10-
/// end falls inside rewritten text, or where the dialect has no native analysis.
8+
/// Each cleaned-SQL byte range as a range of the SQL `passes` rewrote into `analysis_sql`.
119
#[must_use]
1210
pub fn query_expression_ranges(
1311
dialect: &str,
@@ -19,7 +17,7 @@ pub fn query_expression_ranges(
1917
return vec![None; ranges.len()];
2018
};
2119
let Ok((cleaned, offsets)) =
22-
normalize_native_sql_mapped(analysis_sql, dialect, module.stub_prefix(), placeholders)
20+
normalize_native_filled(analysis_sql, dialect, module.stub_prefix(), placeholders)
2321
else {
2422
return vec![None; ranges.len()];
2523
};
@@ -35,6 +33,9 @@ pub fn query_expression_ranges(
3533
characters[cleaned.len()] = Some(cleaned.chars().count());
3634
let query_byte = |cleaned_byte: usize| -> Option<usize> {
3735
let character: usize = (*characters.get(cleaned_byte)?)?;
36+
if !copied_boundary(&offsets, character) {
37+
return None;
38+
}
3839
let byte: usize = *analysis_bytes.get(*offsets.get(character)?)?;
3940
passes
4041
.iter()
@@ -51,8 +52,18 @@ pub fn query_expression_ranges(
5152
.collect()
5253
}
5354

54-
/// The source byte of output byte `byte` of a pass whose `splices` are in text order; a byte at
55-
/// a splice's edge maps to the same edge of its source, and one inside rewritten text has none.
55+
/// Whether cleaned boundary `character` lies between source-adjacent characters, so maps exactly.
56+
fn copied_boundary(offsets: &[usize], character: usize) -> bool {
57+
let Some(previous) = character.checked_sub(1) else {
58+
return offsets.first() == Some(&0);
59+
};
60+
offsets
61+
.get(previous)
62+
.zip(offsets.get(character))
63+
.is_some_and(|(before, after)| before + 1 == *after)
64+
}
65+
66+
/// The source byte of output `byte`; splice edges map to source edges, inner bytes to `None`.
5667
fn spliced_back(byte: usize, splices: &[TextSplice]) -> Option<usize> {
5768
let (mut source_end, mut output_end): (usize, usize) = (0, 0);
5869
for splice in splices {

0 commit comments

Comments
 (0)