Skip to content

Commit dc9756d

Browse files
authored
refactor(native): move discovery, scopes and the model config build into plain-Rust crates (#1182)
* refactor(native): move discovery's header keys and header changes into sqlbuild-discovery The header key sets each statement accepts and the options built from them now live in `sqlbuild-discovery` (`constants`, `model_file_options`, `declaration_file_options`, `sql_test_file_options`), and the bindings call them. The delta compile's header comparison moves too: `header_changes` digests each header key, decides how an edit changed a model header and whether a custom rule reads a changed key. The bindings keep only the recording of digests into the compile image. The comparison gains unit tests. * refactor(native): move plain scope logic from the bindings into sqlbuild-scopes The conversions from a cached full index to the report and projection views (`cached_report_records`, `cached_projection_index` and their parts), the scope path and qualified identity messages, the `as_posix` path text helpers, and the merge of new runtime usages (`merged_usages`, with `UsageKey`) now live in `sqlbuild-scopes`. The bindings read Python objects into those types and convert the results back, nothing more. `distinct_positions` and `merged_dependencies` become helpers of the usage merge, their only remaining caller. * feat(native): add a typed model config value and layer described models with it `sqlbuild-model-config` gains `config_values`: `ConfigNode<H>` holds the builtin Python values config is made of (None, bool, int, float, str, list, tuple, dict) as Rust data, and every other value as an opaque host value carrying its handle and the facts config logic reads of it, taken once with the same rules as before. It implements `AuthoredNode`, so the existing config scans read it unchanged, and `layered_config` merges one layer over another as `_merged_with_tag_union` does. The bindings convert Python values into nodes and project nodes back; opaque values project to the very same object. Externally described models now layer defaults, path defaults and headers through the typed merge, the first consumer. * refactor(native): build model configs as typed values in sqlbuild-model-config The model config build (layering with tag, row-diff and tolerance unions, hook checks, the three model template passes, the target namespace and its templates, storage key removal, the no-macros check and header keys) now runs in `sqlbuild-model-config::model_config_build` over `ConfigNode`s. It asks its caller only for environment reads, the project-var text of non-string values and the exact error of a value that is not UTF-8 text, through `ConfigBuildHost`. `NativeModelConfigBuilder` converts the project inputs once per compile and each header once per model, and projects the built values back. Opaque values are carried as their own Python objects. Which header entries are still the header's own is tracked by provenance instead of object identity, so native header facts apply exactly where they did. Output is unchanged: the compiler differential passes, and a new integration test pins values, types, order, header keys and errors for builtin, subclassed, str-enum, date and opaque header values against the previous build. The build runs about 2% fewer instructions on a 600-model templated project. * refactor(native): read planner config values as typed nodes `sqlbuild-model-config` gains the typed config reads (`checked_text`, `checked_flag`, `checked_integer`, `checked_texts`, `checked_cursor_bound`) with the type rules and error contents of the `get_config_*` readers; the Python-facing readers now check through them and still return the caller's own objects. A config record keeps its values as typed nodes, converted from `values` on first read and cached on the record. The planner's phase objects, change detection, display plan, column migrations, lineage and SQL test cursor window read through it instead of the Python dict. * fix(native): keep header-owned config values as the header's objects Since configs are built as typed nodes, every built container was projected as a fresh Python object. The model loop reuses natively parsed header columns only when `values["columns"]` is the header's own object, so any model with `columns` beside another config key fell back to parsing its columns again in Python. The build now reports which header keys still hold the header's own values (the provenance it already tracks, with set-aside hooks included), and the projection uses the header's objects for them, so identity holds exactly where it did before. Stage counters record whether a model's header columns were reused or parsed again, and an integration test pins the native path for models with `columns` beside `materialized`, `tags` or a description. * fix(native): read config value text through Python when only Python prints it exactly Opaque config values were read eagerly at conversion: `str()` twice, `repr()`, a date check and three hook-entry checks, with a raising `__repr__` read as empty. Only cheap facts (kind, text, numbers) are now read when a value is converted. Date checks and display text are asked of the value's handle when a scan needs them, and the build asks its host for `str()` and hook-entry checks, so their errors raise as before. Text is produced in Rust only where it is exact: `None`, booleans, integers, floats and a string's own text. A string's repr, containers and opaque values use Python's `str()`/`repr()` of the projected object for template operands and tag/row-diff unions. That replaces an approximate Rust repr that printed some format, private-use and unassigned characters unescaped. Hook-template context values read only `database`, `schema` and `alias` instead of converting the whole values dict. Integration tests pin container text, item errors in template operands and unions, and that the build never reads an opaque value's text. * perf(native): carry header containers the config build never looks into whole Every header value was converted into config nodes, then cloned through layering and expansion, even large `columns` trees the build only passes through. A header container whose native parse found no template or macro, under a key the build never reads item by item (anything but tags, row-diff, hook and storage keys), is now carried as the header's own object. Dense compiles return to the instruction count they had before the typed build. Because such containers stay the header's own objects, models whose other config values hold templates also reuse their natively parsed columns now; an integration case pins it. * perf(native): convert typed config values key by key on first read The first typed read of a config record converted every value in `config.values` into typed nodes, just to read one scalar key such as `incremental_mode`. A config record now keeps a per-key cache: a value is converted the first time its key is read, through the same `values.get(key)`, and kept for later reads. Checked reads keep their type rules and errors. Lineage's microbatch check compares the mode text as Python's `==` does again, so a string that cannot be encoded reads as not-microbatch instead of raising. A stage counter records each typed conversion, and an integration test pins that keys no planner reader reads are never converted.
1 parent 39dbb57 commit dc9756d

146 files changed

Lines changed: 5262 additions & 1590 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.

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/sqlbuild-discovery/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ rust-version.workspace = true
99

1010
[dependencies]
1111
rayon.workspace = true
12+
sqlbuild-cache.workspace = true
1213
sqlbuild-config.workspace = true
1314
sqlbuild-core.workspace = true
1415
sqlbuild-model-config.workspace = true

‎crates/sqlbuild-discovery/src/_helpers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ pub(crate) mod locations;
44
pub(crate) mod pool;
55
pub(crate) mod reading;
66
pub(crate) mod scoped_paths;
7+
pub(crate) mod sorted_keys;
78
pub(crate) mod statement_headers;
89
pub(crate) mod suggestions;

crates/sqlbuild-python/src/bindings/_helpers/discovery/sorted_keys.rs renamed to crates/sqlbuild-discovery/src/_helpers/sorted_keys.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Header key sets as the sorted lists native discovery options take.
1+
//! Header key sets as the sorted lists the discovery options take.
22
33
/// A header key set as owned keys, sorted by code point as Python sorts them.
44
pub(crate) fn sorted_keys(keys: &[&str]) -> Vec<String> {

‎crates/sqlbuild-discovery/src/constants.rs‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,124 @@ pub const AUDIT_ROLE_DIRECTORY: &str = "audits";
4747
pub const LOCAL_AUDIT_ROLE_DIRECTORY: &str = "_audits";
4848
/// The singular audit directory below an audit role.
4949
pub const SINGULAR_AUDIT_DIRECTORY: &str = "singular";
50+
/// Header keys a `MODEL` statement accepts, sorted by code point.
51+
pub const SQL_MODEL_HEADER_KEYS: [&str; 73] = [
52+
"alias",
53+
"append_cursor_inclusive",
54+
"audit_factories",
55+
"audits",
56+
"batch_concurrency",
57+
"batch_size",
58+
"check_columns",
59+
"columns",
60+
"config",
61+
"constants",
62+
"contract",
63+
"cursor",
64+
"cursor_end",
65+
"cursor_future_action",
66+
"cursor_future_max_distance",
67+
"cursor_grain",
68+
"cursor_inputs",
69+
"cursor_start",
70+
"cursor_start_max_action",
71+
"cursor_start_max_ahead",
72+
"cursor_type",
73+
"cursor_watermark_mode",
74+
"database",
75+
"description",
76+
"dynamic_columns",
77+
"enabled",
78+
"engine",
79+
"enums",
80+
"full_refresh",
81+
"historical_input",
82+
"incremental_mode",
83+
"incremental_strategy",
84+
"initial_valid_from",
85+
"invalidate_hard_deletes",
86+
"lookback",
87+
"materialized",
88+
"max_duration",
89+
"max_microbatches",
90+
"merge_exclude_columns",
91+
"microbatch_limit",
92+
"microbatch_strategy",
93+
"migrate_force",
94+
"migrate_from",
95+
"model_schema",
96+
"observed_at",
97+
"old_name_view",
98+
"on_schema_change",
99+
"order_by",
100+
"partition_by",
101+
"placeholders",
102+
"post_hooks",
103+
"pre_hooks",
104+
"primary_key",
105+
"replay_on_change",
106+
"row_diff_exclude_columns",
107+
"row_diff_sample_rows",
108+
"row_diff_sample_seed",
109+
"row_diff_tolerances",
110+
"schema",
111+
"snapshot_full_refresh",
112+
"snapshot_schema_change",
113+
"snapshot_strategy",
114+
"sql_analysis",
115+
"sql_validation",
116+
"table_settings",
117+
"table_type",
118+
"tags",
119+
"time_travel_retention",
120+
"unaccounted_partition_policy",
121+
"unique_key",
122+
"updated_at",
123+
"valid_from_column",
124+
"valid_to_column",
125+
];
126+
/// `MODEL` header keys that were removed and fail with a dedicated message.
127+
pub const REMOVED_SQL_MODEL_HEADER_KEYS: [&str; 1] = ["run_despite_unchanged"];
128+
/// Header keys a `FUNCTION` statement accepts.
129+
pub const SQL_FUNCTION_HEADER_KEYS: [&str; 6] = [
130+
"arguments",
131+
"database",
132+
"description",
133+
"returns",
134+
"schema",
135+
"tags",
136+
];
137+
/// Header keys an `AUDIT` statement accepts.
138+
pub const SQL_AUDIT_HEADER_KEYS: [&str; 11] = [
139+
"always_run",
140+
"evaluation",
141+
"minimum_samples",
142+
"name",
143+
"run_scope",
144+
"sample_count",
145+
"sample_unit",
146+
"severity",
147+
"sql_analysis",
148+
"thresholds",
149+
"value",
150+
];
151+
/// Header keys a `HOOK` statement accepts.
152+
pub const SQL_HOOK_HEADER_KEYS: [&str; 1] = ["description"];
153+
/// Header keys a `TEST` statement accepts.
154+
pub const SQL_TEST_HEADER_KEYS: [&str; 8] = [
155+
"cases",
156+
"cursor_end",
157+
"cursor_start",
158+
"mode",
159+
"name",
160+
"parameters",
161+
"sql_analysis",
162+
"timeout",
163+
];
164+
/// Header keys a `SCENARIO` statement accepts.
165+
pub const SQL_SCENARIO_HEADER_KEYS: [&str; 4] =
166+
["description", "expected_columns", "tags", "timeout"];
167+
/// `SCENARIO (expected_columns ...)` values: compare listed columns, or require every column.
168+
pub const SCENARIO_EXPECTED_COLUMNS_KEY: &str = "expected_columns";
169+
pub const SCENARIO_EXPECTED_COLUMNS_VALUES: [&str; 2] = ["listed", "all"];
170+
pub const SCENARIO_EXPECTED_COLUMNS_ALL: &str = "all";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//! The options declaration discovery parses headers with.
2+
3+
use crate::_helpers::sorted_keys::sorted_keys;
4+
use crate::constants::{SQL_AUDIT_HEADER_KEYS, SQL_FUNCTION_HEADER_KEYS, SQL_HOOK_HEADER_KEYS};
5+
use crate::declaration_files::models::DeclarationFileOptions;
6+
7+
/// The header keys `FUNCTION`, `AUDIT` and `HOOK` statements accept.
8+
#[must_use]
9+
pub fn declaration_file_options() -> DeclarationFileOptions {
10+
DeclarationFileOptions {
11+
function_keys: sorted_keys(&SQL_FUNCTION_HEADER_KEYS),
12+
audit_keys: sorted_keys(&SQL_AUDIT_HEADER_KEYS),
13+
hook_keys: sorted_keys(&SQL_HOOK_HEADER_KEYS),
14+
}
15+
}

‎crates/sqlbuild-discovery/src/declaration_files/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod bare_number_failure;
2+
pub mod declaration_file_options;
23
pub mod discover_declaration_collection;
34
pub mod parse_declaration_text;
45
pub mod python_function_files;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub(crate) mod references;
2+
pub(crate) mod value_encoding;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//! Whether an edited query still references only what the stored model read.
2+
3+
use sqlbuild_sqltext::sql_references::main::extract_sql_references::extract_sql_references;
4+
use sqlbuild_sqltext::sql_references::models::ReferenceExtraction;
5+
use sqlbuild_sqltext::sql_scan::models::LexicalSyntax;
6+
7+
use crate::header_changes::constants::{
8+
LINE_COMMENT, MODEL_KIND, REF_CALL, SEED_CALL, SEED_KIND, SOURCE_CALL, SOURCE_KIND,
9+
};
10+
11+
/// Whether every model, source and seed `query_sql` names was among the stored `upstream`.
12+
pub(crate) fn references_read(query_sql: &str, upstream: &[(String, String)]) -> bool {
13+
let syntax: LexicalSyntax = LexicalSyntax {
14+
line_comment_prefixes: vec![LINE_COMMENT.to_owned()],
15+
..LexicalSyntax::default()
16+
};
17+
let ReferenceExtraction::Extracted(scan) = extract_sql_references(query_sql, &syntax) else {
18+
return false;
19+
};
20+
scan.references.iter().all(|reference| {
21+
let kind: Option<&str> = match reference.kind {
22+
REF_CALL => Some(MODEL_KIND),
23+
SOURCE_CALL => Some(SOURCE_KIND),
24+
SEED_CALL => Some(SEED_KIND),
25+
_ => None,
26+
};
27+
match (kind, &reference.package) {
28+
(Some(kind), None) => stored_upstream(upstream, kind, &reference.name),
29+
_ => true,
30+
}
31+
})
32+
}
33+
34+
/// Whether `upstream` holds the `kind` resource `name`.
35+
fn stored_upstream(upstream: &[(String, String)], kind: &str, name: &str) -> bool {
36+
upstream
37+
.iter()
38+
.any(|(stored_kind, stored_name)| stored_kind == kind && stored_name == name)
39+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Header values as short digests: an unambiguous byte encoding, hashed and truncated.
2+
3+
use sqlbuild_cache::digest::main::bytes_digest::bytes_digest;
4+
use sqlbuild_sqltext::compiler::models::AuthoredValue;
5+
6+
use crate::header_changes::constants::{HEX, KEPT_DIGEST_BYTES};
7+
8+
/// An unambiguous encoding of header values: a tag byte, then length-prefixed parts.
9+
#[derive(Default)]
10+
pub(crate) struct ValueEncoding {
11+
bytes: Vec<u8>,
12+
}
13+
14+
impl ValueEncoding {
15+
fn text(&mut self, tag: u8, text: &str) {
16+
self.bytes.push(tag);
17+
self.bytes
18+
.extend_from_slice(&(text.len() as u64).to_le_bytes());
19+
self.bytes.extend_from_slice(text.as_bytes());
20+
}
21+
22+
fn list(&mut self, tag: u8, items: &[AuthoredValue]) {
23+
self.bytes.push(tag);
24+
self.bytes
25+
.extend_from_slice(&(items.len() as u64).to_le_bytes());
26+
for item in items {
27+
self.value(item);
28+
}
29+
}
30+
31+
fn pairs(&mut self, tag: u8, items: &[(String, AuthoredValue)]) {
32+
self.bytes.push(tag);
33+
self.bytes
34+
.extend_from_slice(&(items.len() as u64).to_le_bytes());
35+
for (key, item) in items {
36+
self.text(b'k', key);
37+
self.value(item);
38+
}
39+
}
40+
41+
pub(crate) fn value(&mut self, value: &AuthoredValue) {
42+
match value {
43+
AuthoredValue::Null => self.bytes.push(b'n'),
44+
AuthoredValue::Boolean(flag) => self.bytes.extend_from_slice(&[b'b', u8::from(*flag)]),
45+
AuthoredValue::BareWord(word) => self.text(b'w', word),
46+
AuthoredValue::String(string) => self.text(b's', string),
47+
AuthoredValue::List(items) => self.list(b'l', items),
48+
AuthoredValue::Map(items) => self.pairs(b'm', items),
49+
AuthoredValue::Set(items) => self.list(b'e', items),
50+
AuthoredValue::Tuple(items) => self.list(b't', items),
51+
AuthoredValue::TypedConstant(items) => self.pairs(b'c', items),
52+
AuthoredValue::InlineSqlHook(sql) => self.text(b'i', sql),
53+
AuthoredValue::NamedSqlHook(name, items) => {
54+
self.text(b'h', name);
55+
self.pairs(b'a', items);
56+
}
57+
AuthoredValue::PythonHook(name, items) => {
58+
self.text(b'p', name);
59+
self.pairs(b'a', items);
60+
}
61+
}
62+
}
63+
}
64+
65+
pub(crate) fn short_hex(digest: &[u8]) -> String {
66+
let mut hex: String = String::with_capacity(KEPT_DIGEST_BYTES * 2);
67+
for byte in &digest[..KEPT_DIGEST_BYTES] {
68+
hex.push(char::from(HEX[usize::from(byte >> 4)]));
69+
hex.push(char::from(HEX[usize::from(byte & 0x0f)]));
70+
}
71+
hex
72+
}
73+
74+
/// The short digest of one header value.
75+
pub(crate) fn value_digest(value: &AuthoredValue) -> String {
76+
let mut encoding: ValueEncoding = ValueEncoding::default();
77+
encoding.value(value);
78+
short_hex(&bytes_digest(&encoding.bytes))
79+
}

0 commit comments

Comments
 (0)