Skip to content

Commit 3a12e4e

Browse files
authored
perf(compile): faster discovery, scope visibility and warm writes (#930)
* perf(compile): index scope visibility by declaration scope Group declarations by scope and owner once per scope lookup so a resource's visible declarations come from owner and ancestor lookups instead of classifying every declaration. Compile callers only need the identities of inaccessible declarations, so they use a resolver that skips per-pair inaccessible reasons. The full resolver keeps its output. * perf(compile): walk discovery roots once and match model headers natively Discovery queried the project tree with dozens of recursive globs per compile: once per declaration kind, once per layout validator, and once per resource category. A discovery pass now reads each directory listing once into a scoped snapshot that answers every glob with pathlib's matching and symlink semantics, memoizes the declaration facts and group layout, and classifies scoped-tree membership once per directory. The snapshot lives only for one pass, so later passes see current files. MODEL headers are matched by an exact native equivalent of the possessive header pattern in one batch call. * perf(compile): compare warm artifacts in one open and prune only empty folders Warm writes compared every compiled artifact with a stat followed by a separate read; they now open each file once. The stale-file walk tried to remove every compiled directory and relied on failing rmdir calls for non-empty ones; it now removes only folders left empty by pruning, with the same result. Unchanged artifacts keep their bytes and mtimes. * docs: explain which path default describes a model The format page said a model described by [defaults] passes the description check, but [defaults] rejects description with D001. Project configuration now documents that each model uses only its nearest matching path default, that path defaults are not merged, and how to describe models when a broad "**" key coexists with more specific keys.
1 parent e61fa67 commit 3a12e4e

39 files changed

Lines changed: 1973 additions & 197 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,19 @@ fn parse_model_headers(py: Python<'_>, headers: Vec<String>) -> PyResult<Vec<Par
376376
.collect()
377377
}
378378

379+
#[pyfunction]
380+
fn match_model_headers(
381+
py: Python<'_>,
382+
contents: Vec<String>,
383+
) -> PyResult<Vec<Option<(usize, usize, usize)>>> {
384+
py.compiler_detach(|| {
385+
Ok(crate::compiler::main::model_header_matching::match_batch(
386+
&contents,
387+
))
388+
})
389+
.map_err(value_error)
390+
}
391+
379392
#[pyfunction]
380393
fn tokenize_model_header(header: &str) -> PyResult<Vec<(u8, String, usize)>> {
381394
compiler_guard(|| {
@@ -492,6 +505,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
492505
module
493506
)?)?;
494507
module.add_function(wrap_pyfunction!(extract_sql_tests_json, module)?)?;
508+
module.add_function(wrap_pyfunction!(match_model_headers, module)?)?;
495509
module.add_function(wrap_pyfunction!(parse_model_headers, module)?)?;
496510
module.add_function(wrap_pyfunction!(tokenize_model_header, module)?)?;
497511
module.add_function(wrap_pyfunction!(substitute_static_project_vars, module)?)?;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Exact native equivalent of the possessive Python `MODEL(...)` header regular expression.
2+
3+
/// Code-point offsets of one matched header: header start, header end, and SQL start.
4+
pub(crate) type HeaderMatchOffsets = (usize, usize, usize);
5+
6+
const MODEL_KEYWORD: &[u8] = b"MODEL";
7+
8+
pub(crate) fn match_one(text: &str) -> Option<HeaderMatchOffsets> {
9+
let bytes = text.as_bytes();
10+
let mut index = skip_whitespace(text, 0);
11+
if !bytes[index..].starts_with(MODEL_KEYWORD) {
12+
return None;
13+
}
14+
index = skip_whitespace(text, index + MODEL_KEYWORD.len());
15+
if bytes.get(index) != Some(&b'(') {
16+
return None;
17+
}
18+
let header_start = index + 1;
19+
let header_end = scan_header_body(text, header_start);
20+
if bytes.get(header_end) != Some(&b')') {
21+
return None;
22+
}
23+
index = skip_whitespace(text, header_end + 1);
24+
if bytes.get(index) != Some(&b';') {
25+
return None;
26+
}
27+
let sql_start = skip_whitespace(text, index + 1);
28+
Some(code_point_offsets(
29+
text,
30+
header_start,
31+
header_end,
32+
sql_start,
33+
))
34+
}
35+
36+
/// Consume the possessive header body and return the byte offset where it stops.
37+
fn scan_header_body(text: &str, start: usize) -> usize {
38+
let bytes = text.as_bytes();
39+
let mut index = start;
40+
while let Some(&byte) = bytes.get(index) {
41+
index = match byte {
42+
b'"' | b'\'' => quoted_string_end(text, index, byte)
43+
.unwrap_or_else(|| unclosed_quote_end(text, index)),
44+
b')' => {
45+
let after = skip_whitespace(text, index + 1);
46+
if bytes.get(after) == Some(&b';') {
47+
return index;
48+
}
49+
index + 1
50+
}
51+
b'\\' => match escape_end(text, index) {
52+
Some(end) => end,
53+
None => return index,
54+
},
55+
_ => index + 1,
56+
};
57+
}
58+
index
59+
}
60+
61+
/// `"(?:[^"\\]|\\.)*+"`: the closing quote's end, or `None` when the string never closes.
62+
fn quoted_string_end(text: &str, open: usize, quote: u8) -> Option<usize> {
63+
let bytes = text.as_bytes();
64+
let mut index = open + 1;
65+
while let Some(&byte) = bytes.get(index) {
66+
if byte == quote {
67+
return Some(index + 1);
68+
}
69+
if byte == b'\\' {
70+
index = escape_end(text, index)?;
71+
} else {
72+
index += 1;
73+
}
74+
}
75+
None
76+
}
77+
78+
/// `["'](?:[^\\)]|\\.)*+`: an unclosed quote runs to the next `)` or unescapable backslash.
79+
fn unclosed_quote_end(text: &str, open: usize) -> usize {
80+
let bytes = text.as_bytes();
81+
let mut index = open + 1;
82+
while let Some(&byte) = bytes.get(index) {
83+
match byte {
84+
b')' => return index,
85+
b'\\' => match escape_end(text, index) {
86+
Some(end) => index = end,
87+
None => return index,
88+
},
89+
_ => index += 1,
90+
}
91+
}
92+
index
93+
}
94+
95+
/// `\\.` with DOTALL: a backslash plus one following code point, if any.
96+
fn escape_end(text: &str, backslash: usize) -> Option<usize> {
97+
text[backslash + 1..]
98+
.chars()
99+
.next()
100+
.map(|escaped| backslash + 1 + escaped.len_utf8())
101+
}
102+
103+
fn skip_whitespace(text: &str, start: usize) -> usize {
104+
let mut index = start;
105+
for character in text[start..].chars() {
106+
if !is_python_whitespace(character) {
107+
break;
108+
}
109+
index += character.len_utf8();
110+
}
111+
index
112+
}
113+
114+
/// Python's `str.isspace()`, which `\s` uses for `str` patterns.
115+
fn is_python_whitespace(character: char) -> bool {
116+
matches!(
117+
character,
118+
'\t'..='\r'
119+
| '\u{1c}'..='\u{20}'
120+
| '\u{85}'
121+
| '\u{a0}'
122+
| '\u{1680}'
123+
| '\u{2000}'..='\u{200a}'
124+
| '\u{2028}'
125+
| '\u{2029}'
126+
| '\u{202f}'
127+
| '\u{205f}'
128+
| '\u{3000}'
129+
)
130+
}
131+
132+
fn code_point_offsets(
133+
text: &str,
134+
header_start: usize,
135+
header_end: usize,
136+
sql_start: usize,
137+
) -> HeaderMatchOffsets {
138+
if text.is_ascii() {
139+
return (header_start, header_end, sql_start);
140+
}
141+
let header_start_chars = text[..header_start].chars().count();
142+
let header_end_chars = header_start_chars + text[header_start..header_end].chars().count();
143+
let sql_start_chars = header_end_chars + text[header_end..sql_start].chars().count();
144+
(header_start_chars, header_end_chars, sql_start_chars)
145+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub(crate) mod matching;
12
pub(crate) mod tokenization;

‎crates/sqlbuild-rules-native/src/compiler/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub(crate) mod model_header_matching;
12
pub(crate) mod model_header_parsing;
23
pub(crate) mod model_header_tokenizing;
34
pub(crate) mod sql_interpolation;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use crate::compiler::_helpers::model_headers::matching::{self, HeaderMatchOffsets};
2+
3+
pub(crate) fn match_batch(contents: &[String]) -> Vec<Option<HeaderMatchOffsets>> {
4+
contents
5+
.iter()
6+
.map(|text| matching::match_one(text))
7+
.collect()
8+
}

‎crates/sqlbuild-rules-native/src/compiler/tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#[path = "tests/helpers.rs"]
22
mod helpers;
3+
#[path = "tests/test_model_header_matching.rs"]
4+
mod model_header_matching;
35
#[path = "tests/test_model_header_tokenization.rs"]
46
mod model_header_tokenization;
57
#[path = "tests/test_sql_interpolation.rs"]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
use crate::compiler::main::model_header_matching::match_batch;
2+
use crate::compiler::tests::test_types::ModelHeaderMatchTestCase;
3+
4+
#[test]
5+
fn given_model_files_when_matching_headers_then_offsets_follow_the_python_pattern() {
6+
let test_cases = [
7+
ModelHeaderMatchTestCase {
8+
description: "simple header",
9+
contents: "MODEL (name orders);\nSELECT 1",
10+
expected_offsets: Some((7, 18, 21)),
11+
},
12+
ModelHeaderMatchTestCase {
13+
description: "leading whitespace and spaced terminator",
14+
contents: " \n MODEL\t( a ) \n ; \n SELECT 1",
15+
expected_offsets: Some((10, 13, 21)),
16+
},
17+
ModelHeaderMatchTestCase {
18+
description: "closing parenthesis not followed by a semicolon stays in the header",
19+
contents: "MODEL (columns (id INT));SELECT 1",
20+
expected_offsets: Some((7, 23, 25)),
21+
},
22+
ModelHeaderMatchTestCase {
23+
description: "quoted closing parenthesis and semicolon stay in the header",
24+
contents: "MODEL (description 'a);b');SELECT 1",
25+
expected_offsets: Some((7, 25, 27)),
26+
},
27+
ModelHeaderMatchTestCase {
28+
description: "unclosed quote runs to the next parenthesis",
29+
contents: "MODEL (description 'open);SELECT 1",
30+
expected_offsets: Some((7, 24, 26)),
31+
},
32+
ModelHeaderMatchTestCase {
33+
description: "escaped quote inside a string",
34+
contents: "MODEL (description \"a\\\"b\");SELECT 1",
35+
expected_offsets: Some((7, 25, 27)),
36+
},
37+
ModelHeaderMatchTestCase {
38+
description: "non ascii offsets are code points",
39+
contents: "MODEL (description 'caf\u{e9}')\u{a0};\u{3000}SELECT 1",
40+
expected_offsets: Some((7, 25, 29)),
41+
},
42+
ModelHeaderMatchTestCase {
43+
description: "missing terminator",
44+
contents: "MODEL (name orders)\nSELECT 1",
45+
expected_offsets: None,
46+
},
47+
ModelHeaderMatchTestCase {
48+
description: "trailing lone backslash",
49+
contents: "MODEL (name orders\\",
50+
expected_offsets: None,
51+
},
52+
ModelHeaderMatchTestCase {
53+
description: "lowercase keyword",
54+
contents: "model (name orders);",
55+
expected_offsets: None,
56+
},
57+
ModelHeaderMatchTestCase {
58+
description: "empty file",
59+
contents: "",
60+
expected_offsets: None,
61+
},
62+
];
63+
64+
for test_case in test_cases {
65+
let actual = match_batch(&[test_case.contents.to_string()])[0];
66+
assert_eq!(
67+
actual, test_case.expected_offsets,
68+
"{}",
69+
test_case.description
70+
);
71+
}
72+
}

‎crates/sqlbuild-rules-native/src/compiler/tests/test_types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,9 @@ pub(crate) struct RelationMarkerOracleTestCase {
8686
pub(crate) dialect: &'static str,
8787
pub(crate) expected_mismatches: &'static [&'static str],
8888
}
89+
90+
pub(crate) struct ModelHeaderMatchTestCase {
91+
pub(crate) description: &'static str,
92+
pub(crate) contents: &'static str,
93+
pub(crate) expected_offsets: Option<(usize, usize, usize)>,
94+
}

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/format.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ line_width = 100
5050
`MODEL`, `SCENARIO`, `FUNCTION` and `HOOK` headers must carry a non-empty `description`;
5151
`sqb format --check` fails with `description-present` when one is missing, so pre-commit catches it
5252
before compile does (see [Descriptions](../concepts/models/configuration.md#descriptions)). A model
53-
described by `[defaults]`, `[path_defaults]` or its `model_schema` passes, exactly as it compiles.
53+
described by its `model_schema`, or by the one `[path_defaults]` entry that applies to it, passes,
54+
exactly as it compiles. `[defaults]` does not accept `description` (`D001`), and only the nearest
55+
matching path default applies (see
56+
[Which path default applies](../concepts/project-configuration.md#which-path-default-applies)).
5457
Format does not render templates, so a templated description that renders empty passes format and
5558
is reported by `sqb compile`.
5659
Plain `sqb format` still formats the file and exits non-zero, like any other remaining fault.

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,52 @@ replay_on_change = "full"
386386
```
387387

388388
Path matching uses the path below `models/`. A model at `models/staging/stg_orders.sql` matches the
389-
`staging` path default.
389+
`staging` path default. A key matches its folder and every folder below it. In a key, `*` matches one
390+
folder name and `**` matches any number of folders, so `"**"` matches every model.
391+
392+
### Which path default applies
393+
394+
Each model uses at most one path default: the nearest matching key. Path defaults are not merged, so
395+
a model gets nothing from a broader key when a more specific key also matches it.
396+
397+
1. A key without wildcards beats every wildcard key. Among those, the deepest key wins, so
398+
`staging/orders` beats `staging`.
399+
2. When only wildcard keys match, the most specific wins: more literal folder names first, then more
400+
`*` segments, then fewer `**` segments. Two equally specific matches fail compilation.
401+
402+
A broad `"**"` default therefore applies only to models that no other key matches. With the
403+
configuration below, `models/orders.sql` gets the `"**"` description, but
404+
`models/staging/stg_customers.sql` uses only the `staging` entry. It has no description and fails
405+
with `P010`:
406+
407+
```toml
408+
[path_defaults."**"]
409+
description = "Order analytics model."
410+
411+
[path_defaults.staging]
412+
materialized = "view"
413+
```
414+
415+
Descriptions belong to individual models, so put each one in the `MODEL()` header, or in the
416+
`description` of the model's `model_schema`. `[defaults]` rejects `description` with `D001`. If a
417+
folder of models really shares one description, every key those models can select must set it:
418+
419+
```toml
420+
[path_defaults."**"]
421+
description = "Order analytics model."
422+
423+
[path_defaults.staging]
424+
materialized = "view"
425+
description = "Staging model."
426+
```
390427

391428
### Config layering order
392429

393430
Configuration is layered in this order, with later layers overriding earlier ones:
394431

395432
1. **Project defaults** (`defaults`)
396-
2. **Path defaults** (`path_defaults`) - if the model's path matches
433+
2. **Path defaults** (`path_defaults`) - the one nearest matching entry, if any (see
434+
[Which path default applies](#which-path-default-applies))
397435
3. **MODEL() header** - the model's own config
398436

399437
Most keys are overridden by the more specific layer, but three merge instead:

0 commit comments

Comments
 (0)