Skip to content

Commit be45fd8

Browse files
authored
refactor: read analysis natively, plan SQL tests on a native thread and trim hot spots (#1118)
* refactor(analysis): host InferredNullability natively InferredNullability becomes a native StrEnum registered with the compiler enums, so Rust reads it through the typed registry instead of importing sqlbuild.compiler.lineage.types in thirteen places. Python callers import it from sqlbuild._native and the shape-only module is deleted. Stage captures keep the original captured shape name. * refactor(analysis): read compiled models' analysis as native data Semantic shapes, coverage, type recovery, completion, metadata and lineage read each compiled model's inferred columns, declared columns, star flags, unchecked outputs, lineage, binding validation and dynamic proof through typed accessors. Native model views answer from their owner's Rust facts without building Python projections; hand-built records are converted at the read. * refactor(rules): read proven dynamic contracts natively for SQL rule paths * refactor: plan SQL tests on a native thread instead of a Python thread and future SQL test planning ran on a Python threading.Thread whose target was the native planning function, reporting through a concurrent.futures.Future. It now runs on a Rust thread that attaches to the interpreter and sends its outcome over a channel; the planner and `start_sql_test_planning` read it through the native `SqlTestPlanning` handle. The atexit hook waits, without the GIL and for at most five seconds, until no planning thread runs, so finalization never ends one inside native code. The planning entry is still looked up on the module, so tests can replace it. * refactor: call per-model planner helpers directly instead of through the module Model plan entries, audit entries, relation contexts and cursor resolution looked up native helpers on `sqlbuild._native` and called them with keyword dictionaries once or more per model. They now call the Rust functions directly: authored config strings and cursor bounds, the model plan action, SQL, hooks, full-refresh and microbatch limit resolution, model plan entry assembly, audit selection, attachment kind, effective run scope and lineage reads. Results are unchanged. * perf(sqltext): reject code bytes before dialect comment and quote checks Dialect non-code scanning is called once per byte by many SQL test and analysis scanners. Return early when the byte cannot open a block comment, line comment, dollar quote or quoted text, instead of running prefix comparisons at every offset. Results are unchanged. * perf(rules): run the interpolation-site pattern only at possible site starts Lint and format site patterns can only match at '@', '$' or '_'; skip the anchored regex search at every other code byte. * perf(analysis): find the end of a SQL test select list in one scan Projection-name validation scanned each branch once per clause keyword. Scan once and stop at the first top-level FROM or clause keyword with the same name and IS [NOT] DISTINCT FROM rules. * perf(cache): stop re-hashing replay stdout when reading a stored compile The store's whole-body checksum already verifies the stdout record stored under its digest, so reading a project reuse entry no longer hashes the full compile output a second time. Persisted bytes are unchanged. * perf: use FxHash indexes for hot in-memory lookups Add rustc-hash (already in the lock file) as the workspace fast hasher. Index hosted shape names once instead of scanning every shape per lookup, match UNION BY NAME outputs through a name index instead of quadratic scans, and use FxHash for hot sets and maps in function-name validation, CTE column rules, lint additional facts and SQL test identifier tokens. None of these maps' iteration order reaches output. * perf: stream the custom-rule host payload pickle to disk The parent pickled the whole host payload into one bytes object before writing it, holding a second copy of the payload (about 285 MB for a 10,000-model project) alive during the rules phase. Pickle straight into the partial file instead; the atomic rename and the payload the hosts read are unchanged. * chore: drop the unused hosted shape index after the registry replaced reverse lookups * fix: mark SQL test planning finished on panic and drop an unused cache dependency
1 parent d089039 commit be45fd8

109 files changed

Lines changed: 1071 additions & 724 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: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ ruff_python_ast = "=0.0.4"
8484
ruff_python_parser = "=0.0.4"
8585
ruff_text_size = "=0.0.4"
8686
# Unix only: `sqb` shares the compute log prune lock with Python's `filelock` through `flock`.
87+
# Fast deterministic hasher for hot in-memory maps whose iteration order never reaches output.
88+
rustc-hash = "2"
8789
rustix = { version = "1.1.4", default-features = false, features = ["fs", "std"] }
8890
saphyr-parser = "0.1"
8991
regex = "1"

‎crates/sqlbuild-analysis/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ polyglot-sql.workspace = true
1717
polyglot-sql-function-catalogs.workspace = true
1818
rayon.workspace = true
1919
regex.workspace = true
20+
rustc-hash.workspace = true
2021
serde.workspace = true
2122
serde_json.workspace = true
2223
sha2.workspace = true

‎crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs‎

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ const SCENARIO_CONTEXT: &str = "SQL scenario";
3939
const DECLARATION_CALLS: [&str; 4] = ["enum", "const", "var", "param"];
4040
/// Longest projection the alias help repeats verbatim.
4141
const ALIAS_HELP_EXPRESSION_LIMIT: usize = 80;
42+
const FROM_KEYWORD: &str = "FROM";
43+
const SELECT_LIST_END_KEYWORDS: [&str; 10] = [
44+
FROM_KEYWORD,
45+
"WHERE",
46+
"GROUP",
47+
"HAVING",
48+
"QUALIFY",
49+
"WINDOW",
50+
"ORDER",
51+
"LIMIT",
52+
"OFFSET",
53+
"FETCH",
54+
];
4255
const RESERVED_NAMES: [&str; 5] = [
4356
"__actual",
4457
"__expected__typed",
@@ -777,15 +790,7 @@ fn projection_names(
777790
}
778791
}
779792
}
780-
let mut end = select_list_from(branch, select_end, syntax)?.unwrap_or(branch.len());
781-
for keyword in [
782-
"WHERE", "GROUP", "HAVING", "QUALIFY", "WINDOW", "ORDER", "LIMIT", "OFFSET", "FETCH",
783-
] {
784-
if let Some(position) = find_top_level_clause_keyword(branch, select_end, keyword, syntax)?
785-
{
786-
end = end.min(position);
787-
}
788-
}
793+
let end = select_list_end(branch, select_end, syntax)?.unwrap_or(branch.len());
789794
let expressions = split_top_level(&branch[select_end..end], b',', syntax)?;
790795
if expressions.is_empty() {
791796
return Err(
@@ -1379,20 +1384,32 @@ pub(crate) fn find_top_level_keyword(
13791384
})
13801385
}
13811386

1382-
/// The top-level `FROM` that ends a select list, skipping the `FROM` of `IS [NOT] DISTINCT FROM`.
1383-
fn select_list_from(
1387+
/// The first top-level clause keyword ending a select list, skipping names and `IS DISTINCT FROM`.
1388+
fn select_list_end(
13841389
sql: &str,
13851390
start: usize,
13861391
syntax: &LexicalSyntax,
13871392
) -> Result<Option<usize>, String> {
1388-
let mut search_start = start;
1389-
while let Some(position) = find_top_level_clause_keyword(sql, search_start, "FROM", syntax)? {
1390-
if !trailing_distinct_operator(&sql[start..position], syntax)? {
1391-
return Ok(Some(position));
1393+
scan_code(sql, start, syntax, |index, depth| {
1394+
let Some(first) = byte_at(sql, index).filter(|_| depth == 0) else {
1395+
return Ok(CodeStep::Advance);
1396+
};
1397+
let first = first.to_ascii_uppercase();
1398+
let Some(keyword) = SELECT_LIST_END_KEYWORDS
1399+
.iter()
1400+
.filter(|keyword| keyword.as_bytes()[0] == first)
1401+
.find(|keyword| consume_keyword(sql, index, keyword).is_some())
1402+
else {
1403+
return Ok(CodeStep::Advance);
1404+
};
1405+
if previous_code_byte(sql, index, syntax)? == Some(b'.') {
1406+
return Ok(CodeStep::Advance);
13921407
}
1393-
search_start = position + "FROM".len();
1394-
}
1395-
Ok(None)
1408+
if *keyword == FROM_KEYWORD && trailing_distinct_operator(&sql[start..index], syntax)? {
1409+
return Ok(CodeStep::Advance);
1410+
}
1411+
Ok(CodeStep::Stop(index))
1412+
})
13961413
}
13971414

13981415
/// Whether `prefix` ends with `IS DISTINCT` or `IS NOT DISTINCT`, ignoring comments.
@@ -1437,22 +1454,6 @@ fn trailing_word<'a>(
14371454
Ok(word_start.map(|start| (&code[..start], &code[start..])))
14381455
}
14391456

1440-
fn find_top_level_clause_keyword(
1441-
sql: &str,
1442-
start: usize,
1443-
keyword: &str,
1444-
syntax: &LexicalSyntax,
1445-
) -> Result<Option<usize>, String> {
1446-
let mut search_start = start;
1447-
while let Some(position) = find_top_level_keyword(sql, search_start, keyword, syntax)? {
1448-
if previous_code_byte(sql, position, syntax)? != Some(b'.') {
1449-
return Ok(Some(position));
1450-
}
1451-
search_start = position + keyword.len();
1452-
}
1453-
Ok(None)
1454-
}
1455-
14561457
fn previous_code_byte(sql: &str, end: usize, syntax: &LexicalSyntax) -> Result<Option<u8>, String> {
14571458
let mut previous = None;
14581459
let mut index = 0;

‎crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/helper_scope.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
55
use sqlbuild_core::graph_walk::main::depth_first_postorder::depth_first_postorder;
66

77
use polyglot_sql::Dialect;
8+
use rustc_hash::FxHashSet;
89

910
use crate::compiler::_helpers::sql_tests::cte_rename::defined_cte_keys;
1011
use crate::compiler::_helpers::sql_tests::cte_slices::SliceDialect;
@@ -549,7 +550,7 @@ fn is_generated_cte_name(name: &str) -> bool {
549550
/// Distinct lowercase identifiers outside strings and comments, in first-appearance order.
550551
fn identifier_tokens(sql: &str, patterns: &SqlTestPatterns) -> Vec<String> {
551552
let protected = protected_ranges(&patterns.lexical, sql);
552-
let mut seen: HashSet<String> = HashSet::new();
553+
let mut seen: FxHashSet<String> = FxHashSet::default();
553554
let mut tokens: Vec<String> = Vec::new();
554555
let mut push = |token: &str| {
555556
let lowered = token.to_ascii_lowercase();

‎crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction_rules.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@ fn given_authored_sql_tests_when_extracting_then_each_rule_holds() {
169169
sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 'completed' IS DISTINCT FROM 'x' AS status, a IS NOT /* c */ DISTINCT FROM b AS same FROM __ref__orders) SELECT 1",
170170
expected_outcome: accepted_outcome(false),
171171
},
172+
ExtractionRuleTestCase {
173+
description: "clause keywords after a dot or inside parentheses do not end the select list",
174+
mode: "model",
175+
raw: false,
176+
syntax: "generic",
177+
sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT t.order, t.limit AS l, (SELECT max(id) FROM __ref__orders WHERE id > 0 ORDER BY 1) AS m FROM __ref__orders t WHERE t.id > 0 ORDER BY 1) SELECT 1",
178+
expected_outcome: accepted_outcome(false),
179+
},
180+
ExtractionRuleTestCase {
181+
description: "the first top-level clause keyword ends a select list without FROM",
182+
mode: "model",
183+
raw: false,
184+
syntax: "generic",
185+
sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id WHERE TRUE LIMIT 1) SELECT 1",
186+
expected_outcome: accepted_outcome(false),
187+
},
172188
ExtractionRuleTestCase {
173189
description: "IS DISTINCT FROM without an alias is rejected",
174190
mode: "model",

‎crates/sqlbuild-analysis/src/query_analysis/_helpers/borrowed_facts.rs‎

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
//! Immutable CTE output graph over one borrowed syntax tree.
22
33
use std::cell::Cell;
4-
use std::collections::{BTreeSet, HashMap, HashSet};
4+
use std::collections::BTreeSet;
55
use std::rc::Rc;
66

77
use polyglot_sql::expressions::{Column, Join, JoinKind, Select, Star, Values, With};
88
use polyglot_sql::{
99
DialectType, Expression, ExpressionWalk, ProjectionNullability, TransformKind, ValidationSchema,
1010
};
11+
use rustc_hash::{FxHashMap, FxHashSet};
12+
1113
const SQL_WILDCARD: &str = "*";
1214

1315
#[derive(Clone, Debug)]
@@ -22,14 +24,14 @@ pub(super) struct OutputFact {
2224
#[derive(Clone)]
2325
struct Source {
2426
outputs: Rc<Vec<OutputFact>>,
25-
columns: Rc<HashMap<String, Option<usize>>>,
27+
columns: Rc<FxHashMap<String, Option<usize>>>,
2628
physical: Option<String>,
2729
null_extended: bool,
2830
}
2931

3032
impl Source {
3133
fn new(outputs: Vec<OutputFact>, physical: Option<String>) -> Self {
32-
let mut columns: HashMap<String, Option<usize>> = HashMap::new();
34+
let mut columns: FxHashMap<String, Option<usize>> = FxHashMap::default();
3335
for (index, output) in outputs.iter().enumerate() {
3436
let key = output.name.to_lowercase();
3537
let value = (!columns.contains_key(&key)).then_some(index);
@@ -44,13 +46,13 @@ impl Source {
4446
}
4547
}
4648

47-
type Relations = HashMap<String, Source>;
49+
type Relations = FxHashMap<String, Source>;
4850
#[derive(Default)]
4951
struct Sources {
5052
items: Vec<(String, Source)>,
51-
using_columns: HashMap<String, OutputFact>,
53+
using_columns: FxHashMap<String, OutputFact>,
5254
}
53-
type Aliases = HashMap<String, Option<OutputFact>>;
55+
type Aliases = FxHashMap<String, Option<OutputFact>>;
5456

5557
struct BindingProof {
5658
enabled: bool,
@@ -110,7 +112,7 @@ fn infer_with_binding_proof(
110112
| DialectType::Databricks
111113
),
112114
};
113-
let mut relations = Relations::new();
115+
let mut relations = Relations::default();
114116
if let Some(schema) = schema {
115117
for table in &schema.tables {
116118
let outputs: Vec<OutputFact> = table
@@ -147,7 +149,7 @@ fn query(expression: &Expression, inherited: &Relations, proof: &BindingProof) -
147149
));
148150
let mut relations = inherited.clone();
149151
if let Some(with) = query_with(expression) {
150-
let mut names: HashSet<String> = HashSet::new();
152+
let mut names: FxHashSet<String> = FxHashSet::default();
151153
for cte in &with.ctes {
152154
if proof.enabled {
153155
proof.require(!with.recursive && names.insert(cte.alias.name.to_lowercase()));
@@ -195,11 +197,19 @@ fn set_outputs(
195197
right: Vec<OutputFact>,
196198
by_name: bool,
197199
) -> Vec<OutputFact> {
200+
let mut right_by_name: FxHashMap<String, usize> = FxHashMap::default();
201+
if by_name {
202+
for (index, output) in right.iter().enumerate() {
203+
right_by_name
204+
.entry(output.name.to_ascii_lowercase())
205+
.or_insert(index);
206+
}
207+
}
198208
for (index, output) in left.iter_mut().enumerate() {
199209
let other = if by_name {
200-
right
201-
.iter()
202-
.find(|other| other.name.eq_ignore_ascii_case(&output.name))
210+
right_by_name
211+
.get(&output.name.to_ascii_lowercase())
212+
.map(|position| &right[*position])
203213
} else {
204214
right.get(index)
205215
};
@@ -214,11 +224,12 @@ fn set_outputs(
214224
}
215225
}
216226
if by_name {
227+
let mut left_names: FxHashSet<String> = left
228+
.iter()
229+
.map(|output| output.name.to_ascii_lowercase())
230+
.collect();
217231
for mut output in right {
218-
if !left
219-
.iter()
220-
.any(|other| other.name.eq_ignore_ascii_case(&output.name))
221-
{
232+
if left_names.insert(output.name.to_ascii_lowercase()) {
222233
output.nullability = ProjectionNullability::Nullable;
223234
left.push(output);
224235
}
@@ -273,7 +284,7 @@ fn select_outputs(select: &Select, relations: &Relations, proof: &BindingProof)
273284
sources = joined_sources(sources, join, relations, proof);
274285
}
275286
let mut outputs: Vec<OutputFact> = Vec::new();
276-
let mut aliases = Aliases::new();
287+
let mut aliases = Aliases::default();
277288
for projection in &select.expressions {
278289
let projection = unwrapped(projection);
279290
if let Some(star) = projection_star(projection) {
@@ -402,7 +413,7 @@ fn source(
402413
Expression::Lateral(lateral) if matches!(unwrapped(&lateral.this), Expression::Function(function) if function.name.eq_ignore_ascii_case("FLATTEN")) =>
403414
{
404415
proof.require(false);
405-
let fact = expression_fact(&lateral.this, preceding, &Aliases::new());
416+
let fact = expression_fact(&lateral.this, preceding, &Aliases::default());
406417
let names: Vec<String> = if lateral.column_aliases.is_empty() {
407418
["SEQ", "KEY", "PATH", "INDEX", "VALUE", "THIS"]
408419
.into_iter()
@@ -438,7 +449,7 @@ fn values_outputs(values: &Values) -> Vec<OutputFact> {
438449
for row in &values.expressions {
439450
let mut facts: Vec<OutputFact> = Vec::new();
440451
for (index, expression) in row.expressions.iter().enumerate() {
441-
let mut fact = expression_fact(expression, &Sources::default(), &Aliases::new());
452+
let mut fact = expression_fact(expression, &Sources::default(), &Aliases::default());
442453
fact.name = values.column_aliases.get(index).map_or_else(
443454
|| format!("column{}", index + 1),
444455
|alias| alias.name.clone(),
@@ -483,7 +494,7 @@ fn joined_sources(
483494
items: vec![(name.clone(), right.clone())],
484495
..Default::default()
485496
};
486-
let mut joined: HashMap<String, OutputFact> = HashMap::new();
497+
let mut joined: FxHashMap<String, OutputFact> = FxHashMap::default();
487498
for identifier in &join.using {
488499
let column = Column {
489500
name: identifier.clone(),
@@ -493,8 +504,8 @@ fn joined_sources(
493504
span: None,
494505
inferred_type: None,
495506
};
496-
let left = column_fact(&column, &sources, &Aliases::new());
497-
let right = column_fact(&column, &right_scope, &Aliases::new());
507+
let left = column_fact(&column, &sources, &Aliases::default());
508+
let right = column_fact(&column, &right_scope, &Aliases::default());
498509
proof.require(
499510
left.as_ref().is_some_and(|fact| fact.resolved)
500511
&& right.as_ref().is_some_and(|fact| fact.resolved),
@@ -535,7 +546,7 @@ fn joined_sources(
535546
{
536547
proof.require(
537548
scalar_binding_expression(on)
538-
&& expression_fact(on, &sources, &Aliases::new()).resolved,
549+
&& expression_fact(on, &sources, &Aliases::default()).resolved,
539550
);
540551
}
541552
sources
@@ -708,7 +719,7 @@ fn nullability(
708719

709720
fn star_outputs(star: &Star, sources: &Sources) -> Vec<OutputFact> {
710721
let mut outputs: Vec<OutputFact> = Vec::new();
711-
let mut merged: HashSet<String> = HashSet::new();
722+
let mut merged: FxHashSet<String> = FxHashSet::default();
712723
for (name, source) in &sources.items {
713724
if star
714725
.table
@@ -756,7 +767,7 @@ fn star_outputs(star: &Star, sources: &Sources) -> Vec<OutputFact> {
756767
.find(|alias| alias.alias.name.eq_ignore_ascii_case(&output.name))
757768
{
758769
let name = output.name.clone();
759-
output = expression_fact(&alias.this, sources, &Aliases::new());
770+
output = expression_fact(&alias.this, sources, &Aliases::default());
760771
output.name = name;
761772
}
762773
if let Some((_, alias)) = star

‎crates/sqlbuild-analysis/src/query_analysis/tests/helpers.rs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,25 @@ pub(crate) fn borrowed_facts_preserve_union_dependencies_and_join_nullability()
542542
facts[0].nullability,
543543
polyglot_sql::ProjectionNullability::NonNull
544544
);
545+
let expression = polyglot_sql::parse_one(
546+
"SELECT order_id FROM orders UNION ALL BY NAME SELECT customer_id AS extra_id, customer_id AS ORDER_ID FROM customers",
547+
polyglot_sql::DialectType::Snowflake,
548+
).expect("valid SQL");
549+
let facts = crate::query_analysis::borrowed_facts::infer(
550+
&expression,
551+
Some(&schema),
552+
polyglot_sql::DialectType::Snowflake,
553+
);
554+
assert_eq!(
555+
facts
556+
.iter()
557+
.map(|fact| (fact.name.as_str(), fact.nullability, fact.upstream.len()))
558+
.collect::<Vec<_>>(),
559+
vec![
560+
("order_id", polyglot_sql::ProjectionNullability::NonNull, 2),
561+
("extra_id", polyglot_sql::ProjectionNullability::Nullable, 1),
562+
]
563+
);
545564
let expression = polyglot_sql::parse_one(
546565
"SELECT o.order_id, c.customer_id FROM orders o LEFT JOIN customers c ON o.order_id = c.customer_id",
547566
polyglot_sql::DialectType::Snowflake,

0 commit comments

Comments
 (0)