Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Borrowed-tree type evaluation with immutable lexical CTE outputs.

use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;

Expand Down Expand Up @@ -38,6 +39,26 @@ pub(super) fn infer_outputs(
functions: &HashMap<String, String>,
dialect: DialectType,
) -> Outputs {
evaluate_outputs(expression, schema, functions, dialect).0
}

/// Return outputs only when evaluation never consults mutable AST type annotations.
pub(super) fn infer_unannotated_outputs(
expression: &Expression,
schema: Option<&ValidationSchema>,
functions: &HashMap<String, String>,
dialect: DialectType,
) -> Option<Outputs> {
let (outputs, used_annotations) = evaluate_outputs(expression, schema, functions, dialect);
(!used_annotations).then_some(outputs)
}

fn evaluate_outputs(
expression: &Expression,
schema: Option<&ValidationSchema>,
functions: &HashMap<String, String>,
dialect: DialectType,
) -> (Outputs, bool) {
let mut relations = Relations::new();
if let Some(schema) = schema {
for table in &schema.tables {
Expand All @@ -53,13 +74,19 @@ pub(super) fn infer_outputs(
);
}
}
let context = TypeContext { functions, dialect };
context.query(expression, &relations)
let context = TypeContext {
functions,
dialect,
used_annotations: Cell::new(false),
};
let outputs = context.query(expression, &relations);
(outputs, context.used_annotations.get())
}

struct TypeContext<'a> {
functions: &'a HashMap<String, String>,
dialect: DialectType,
used_annotations: Cell<bool>,
}

impl TypeContext<'_> {
Expand Down Expand Up @@ -222,6 +249,7 @@ impl TypeContext<'_> {
Some(NULL_TYPE.to_string())
} else {
self.expression(expression, aliases).or_else(|| {
self.used_annotations.set(true);
expression
.inferred_type()
.filter(|data_type| **data_type != DataType::Unknown)
Expand Down
90 changes: 82 additions & 8 deletions crates/sqlbuild-rules-native/src/query_analysis/_helpers/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,12 +461,26 @@ fn try_borrowed_query(
}) {
return Err(Box::new(work));
}
let bound_facts = work
.query
.schema
.as_ref()
.filter(|schema| {
plain_binding_schema(schema)
&& work.query.binding_schema.as_ref().is_none_or(|binding| {
plain_binding_schema(binding) && same_binding_columns(schema, binding)
})
})
.and_then(|schema| super::borrowed_facts::infer_bound(&expression, Some(schema), dialect));
let validation = work
.query
.binding_schema
.as_ref()
.or(work.query.schema.as_ref())
.map(|schema| {
if bound_facts.is_some() {
return Ok(ValidationResult::with_errors(Vec::new()));
}
let result = polyglot_sql::validation::validate_parsed_with_schema(
vec![expression.clone()],
dialect,
Expand All @@ -493,7 +507,19 @@ fn try_borrowed_query(
if !matches!(validation, Some(Ok(ref result)) if result.valid) {
return Err(Box::new(work));
}
{
let unannotated_outputs: Option<Vec<_>> = work
.projections
.iter()
.map(|(_, projection)| {
super::compatibility_types::infer_unannotated_outputs(
&expression,
work.query.schema.as_ref(),
&projection.function_return_types,
dialect,
)
})
.collect();
if unannotated_outputs.is_none() {
let schema = work.query.schema.as_ref().map(|schema| {
polyglot_sql::validation::mapping_schema_from_validation_schema_with_dialect(
schema, dialect,
Expand All @@ -508,7 +534,9 @@ fn try_borrowed_query(
Some(dialect),
);
}
let facts = super::borrowed_facts::infer(&expression, work.query.schema.as_ref(), dialect);
let facts = bound_facts.unwrap_or_else(|| {
super::borrowed_facts::infer(&expression, work.query.schema.as_ref(), dialect)
});
if facts.is_empty()
|| facts
.iter()
Expand All @@ -523,13 +551,16 @@ fn try_borrowed_query(
return Err(Box::new(work));
}
}
let mut unannotated_outputs = unannotated_outputs.unwrap_or_default().into_iter();
for (index, projection) in &work.projections {
let mut outputs = super::compatibility_types::infer_outputs(
&expression,
work.query.schema.as_ref(),
&projection.function_return_types,
dialect,
);
let mut outputs = unannotated_outputs.next().unwrap_or_else(|| {
super::compatibility_types::infer_outputs(
&expression,
work.query.schema.as_ref(),
&projection.function_return_types,
dialect,
)
});
{
let types: HashMap<_, _> = outputs.into_iter().collect();
outputs = facts
Expand Down Expand Up @@ -603,6 +634,49 @@ fn try_borrowed_query(
})
}

fn same_binding_columns(left: &ValidationSchema, right: &ValidationSchema) -> bool {
if left.tables.len() != right.tables.len() {
return false;
}
for table in &left.tables {
let Some(other) = right
.tables
.iter()
.find(|other| other.name.eq_ignore_ascii_case(&table.name))
else {
return false;
};
if table.columns.len() != other.columns.len()
|| !table
.columns
.iter()
.zip(&other.columns)
.all(|(left, right)| left.name.eq_ignore_ascii_case(&right.name))
{
return false;
}
}
true
}

fn plain_binding_schema(schema: &ValidationSchema) -> bool {
for table in &schema.tables {
if table.schema.is_some()
|| !table.aliases.is_empty()
|| !table.foreign_keys.is_empty()
|| !table.primary_key.is_empty()
|| !table.unique_keys.is_empty()
|| table
.columns
.iter()
.any(|column| column.references.is_some() || column.primary_key || column.unique)
{
return false;
}
}
true
}

fn has_case_sensitive_binding(expression: &polyglot_sql::Expression) -> bool {
use polyglot_sql::Expression;
match expression {
Expand Down
90 changes: 90 additions & 0 deletions crates/sqlbuild-rules-native/src/query_analysis/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,27 @@ pub(crate) fn combined_queries_preserve_standalone_binding() -> bool {
"WITH orders AS (SELECT * FROM orders), typed AS (SELECT CAST(CAST(o.order_id AS INT) AS NUMBER(38, 0)) AS order_id FROM orders o) SELECT order_id FROM typed",
"WITH combined AS (SELECT order_id FROM orders UNION ALL BY NAME SELECT customer_id AS order_id FROM customers), typed AS (SELECT CAST(order_id AS BIGINT) AS order_id FROM combined) SELECT order_id FROM typed",
"SELECT order_id FROM orders QUALIFY missing > 0",
"SELECT order_id FROM orders WHERE missing > 0",
"SELECT order_id FROM orders GROUP BY missing",
"SELECT order_id FROM orders HAVING missing > 0",
"SELECT order_id FROM orders ORDER BY missing",
"SELECT orders.order_id FROM orders JOIN customers ON orders.order_id = customers.missing",
"WITH unused AS (SELECT missing FROM orders) SELECT CAST(1 AS BIGINT) AS order_id",
"SELECT order_id FROM orders WHERE EXISTS (SELECT missing FROM customers)",
"SELECT order_id AS selected_id FROM orders WHERE selected_id > 0",
"SELECT selected_id AS result, order_id AS selected_id FROM orders",
"WITH selected AS (SELECT missing.* FROM orders) SELECT CAST(1 AS BIGINT) AS order_id FROM selected",
"WITH selected AS (SELECT * EXCLUDE (missing) FROM orders) SELECT order_id FROM selected",
"SELECT order_id, ROW_NUMBER() OVER (ORDER BY missing) AS position FROM orders",
"SELECT order_id FROM orders LIMIT 1",
"SELECT a.order_id FROM orders a JOIN orders a ON a.order_id = a.order_id",
"SELECT orders.order_id FROM orders JOIN customers USING (order_id)",
"SELECT orders.order_id FROM orders CROSS JOIN customers",
"SELECT orders.order_id FROM orders JOIN customers",
"WITH combined AS (SELECT order_id FROM orders UNION ALL SELECT missing FROM customers) SELECT order_id FROM combined",
"WITH combined AS (SELECT order_id FROM orders UNION ALL SELECT customer_id, customer_id AS extra FROM customers) SELECT order_id FROM combined",
"WITH combined AS (SELECT order_id FROM orders UNION ALL BY NAME SELECT customer_id FROM customers) SELECT order_id FROM combined",
"SELECT order_id FROM orders UNION ALL SELECT customer_id FROM customers ORDER BY missing",
"SELECT FROM",
"SELECT order_id FROM orders; SELECT customer_id FROM customers",
] {
Expand Down Expand Up @@ -113,6 +133,37 @@ pub(crate) fn combined_queries_preserve_standalone_binding() -> bool {
true
}

pub(crate) fn binding_warnings_preserve_declared_relationship_evidence() -> bool {
let schema = json!({"strict": true, "tables": [
{"name": "orders", "columns": [
{"name": "order_id", "type": "BIGINT"},
{"name": "customer_id", "type": "BIGINT", "references": {"table": "customers", "column": "customer_id"}}
]},
{"name": "customers", "columns": [{"name": "customer_id", "type": "BIGINT"}]}
]});
let sql = "SELECT o.order_id FROM orders o JOIN customers c ON o.order_id = c.customer_id";
let standalone: Value = serde_json::from_str(&crate::semantic_validation::main::validation_json(
&json!({"sql": sql, "dialect": "duckdb", "schema": schema,
"options": {"check_types": false, "check_references": true, "strict": true, "semantic": false, "strict_syntax": false}}).to_string()
).expect("validation succeeds")).expect("valid JSON");
assert!(
standalone["errors"]
.as_array()
.expect("diagnostics")
.iter()
.any(|item| item["code"] == "W221")
);
let response: Value = serde_json::from_str(&analyze_project_compact_json(&json!({
"queries": [{"sql": sql, "dialect": "duckdb", "schema": schema, "binding_schema": schema}],
"templates": [{"queryIndex": 0, "recoverCteFacts": true, "references": {
"orders": {"resourceType": "model", "resourceName": "orders"},
"customers": {"resourceType": "model", "resourceName": "customers"}
}}], "projections": [{"templateIndex": 0}]
}).to_string()).expect("analysis succeeds")).expect("valid JSON");
assert_eq!(response["validations"][0], standalone);
true
}

pub(crate) fn widening_aggregates_require_compatibility_recovery() -> bool {
for expression in [
"AVG(order_id)",
Expand Down Expand Up @@ -320,6 +371,45 @@ pub(crate) fn canonical_queries_reuse_semantics_and_project_resources() -> bool
assert_eq!(strings[second_resource_index], "archived_orders");
true
}
pub(crate) fn unannotated_types_require_complete_nested_evidence() -> bool {
let dialect = polyglot_sql::DialectType::DuckDB;
let functions = std::collections::HashMap::new();
let expression = polyglot_sql::parse_one(
"WITH orders AS (SELECT CAST(1 AS INTEGER) AS order_id) SELECT order_id FROM orders",
dialect,
)
.expect("valid SQL");
let outputs = crate::query_analysis::compatibility_types::infer_unannotated_outputs(
&expression,
None,
&functions,
dialect,
)
.expect("all nested outputs have independently known types");
let mut annotated = expression.clone();
polyglot_sql::optimizer::annotate_types::annotate_types(&mut annotated, None, Some(dialect));
assert_eq!(
outputs,
crate::query_analysis::compatibility_types::infer_outputs(
&annotated, None, &functions, dialect,
)
);
let expression = polyglot_sql::parse_one(
"WITH orders AS (SELECT unknown_function() AS amount) SELECT CAST(amount AS INTEGER) AS amount FROM orders",
dialect,
).expect("valid SQL");
assert!(
crate::query_analysis::compatibility_types::infer_unannotated_outputs(
&expression,
None,
&functions,
dialect,
)
.is_none()
);
true
}

pub(crate) fn native_compatibility_types_preserve_result_semantics() -> bool {
let expression = polyglot_sql::parse_one(
"WITH orders AS (SELECT CAST(1 AS INTEGER) AS order_id), values AS (SELECT CASE WHEN order_id > 0 THEN CAST(order_id AS BIGINT) ELSE NULL END AS result, order_id > 0 AS positive, SUM(order_id) AS total FROM orders GROUP BY order_id) SELECT result, positive, total FROM values",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::query_analysis::main::analyze::analyze_json;
use crate::query_analysis::main::analyze_project::analyze_project_json;
use crate::query_analysis::tests::helpers::{
binding_warnings_preserve_declared_relationship_evidence,
borrowed_facts_preserve_named_outputs_and_terminal_sources,
borrowed_facts_preserve_union_dependencies_and_join_nullability,
canonical_queries_reuse_semantics_and_project_resources,
Expand All @@ -9,6 +10,7 @@ use crate::query_analysis::tests::helpers::{
interleaved_query_templates_preserve_template_order,
native_compatibility_types_preserve_result_semantics,
repeated_project_facts_intern_complete_facts,
unannotated_types_require_complete_nested_evidence,
widening_aggregates_require_compatibility_recovery,
};
use crate::query_analysis::tests::test_types::{
Expand All @@ -19,6 +21,16 @@ use serde_json::{Value, json};
#[test]
fn given_compact_query_cases_when_analyzing_projects_then_expected_behavior_holds() {
let test_cases = [
CompactQueryAnalysisTestCase {
description: "binding warnings preserve declared relationship evidence",
run: binding_warnings_preserve_declared_relationship_evidence,
expected_success: true,
},
CompactQueryAnalysisTestCase {
description: "unannotated types require complete nested evidence",
run: unannotated_types_require_complete_nested_evidence,
expected_success: true,
},
CompactQueryAnalysisTestCase {
description: "borrowed facts preserve named outputs and actual terminal columns",
run: borrowed_facts_preserve_named_outputs_and_terminal_sources,
Expand Down
28 changes: 27 additions & 1 deletion docs/dense-compile-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The guard checks:
| 1,000 | 14s | 1.5GiB |
| 3,000 | 34s | 2.75GiB |
| 5,000 | 55s | 3.25GiB |
| 10,000 | 115s | 4GiB |
| 10,000 | 145s | 4GiB |

These are regression ceilings for the implemented compiler, with headroom for
host variation. They are not a claim of sub-10-second compilation at every size.
Expand All @@ -47,6 +47,32 @@ analysis results in batches of 64. Smaller requests retain a single batch. Both
use up to four workers; local resolution views avoid copying CTE definitions
that are already available through the source catalogue.
These limits change scheduling, not analysis or fallback semantics.
Projects with at least 10,000 model projections and 48MiB of prepared SQL use one
native analysis worker to leave memory headroom for the isolated custom-rule host
after analysis. Smaller SQL payloads retain parallel analysis.
The 10k cold ceiling includes headroom for this memory-bounded scheduling; warm
cache hits and small edit batches keep their existing scheduling.

The compiler reuses borrowed syntax-tree facts when it can prove complete reference
binding and infer types without consulting mutable type annotations. Unsupported
clauses, incomplete schemas and unresolved expressions retain the normal validation
and analysis paths.

For cold projects with 128–5,000 models and selected rules, artifact rendering can overlap rule
evaluation after graph and contract analysis. Rendering uses one background thread
and a disposable directory outside the project. The normal write phase publishes
the staged files only after its existing diagnostic gates pass. Unchanged files
retain their timestamps, stale files are removed using the normal policy, and
cached compilation keeps its normal artifact-cache path. Temporary-storage failure
falls back to ordinary artifact writing.
Larger projects retain sequential rendering to bound combined compiler and rule-host
memory; projects without selected rules avoid staging's extra file I/O.

Detailed phase timings can overlap; physical-write time includes any staging work.
Use whole-process wall time for performance acceptance rather than summing phases.
Custom rules continue to run in their isolated host. Disabled rules caches avoid
unused fact fingerprints; enabled caches retain their normal identities and
invalidation behavior.

This fixture protects dense query analysis, declaration expansion, rules and
artifact generation. It is not an exact reproduction of every application:
Expand Down
Loading
Loading