From a759b7cd4fa3f2a69f4065222369812c0afa2f38 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:26:16 +0000 Subject: [PATCH 1/2] WIP: route each dialect's analysis to Polyglot or a native module Adds analysis_route: a table of dialects whose SQL a native module analyses, empty for now so every dialect keeps the Polyglot path; the NativeDialect trait with read_whole; the native parser's first gap in a model's SQL, normalised and with its placeholders filled as analysis reads it; and the P026 message for a model whose analysis is skipped. Not yet wired into the model loop. --- .../src/analysis_route/constants.rs | 8 ++ .../src/analysis_route/main/analysis_route.rs | 12 ++ .../src/analysis_route/main/mod.rs | 3 + .../src/analysis_route/main/native_gap.rs | 43 +++++++ .../analysis_route/main/skipped_analysis.rs | 15 +++ .../src/analysis_route/mod.rs | 9 ++ .../src/analysis_route/models.rs | 27 +++++ .../src/analysis_route/tests.rs | 4 + .../analysis_route/tests/test_native_gap.rs | 112 ++++++++++++++++++ .../src/analysis_route/tests/test_types.rs | 8 ++ .../src/analysis_route/types.rs | 16 +++ crates/sqlbuild-analysis/src/lib.rs | 1 + 12 files changed, 258 insertions(+) create mode 100644 crates/sqlbuild-analysis/src/analysis_route/constants.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/main/analysis_route.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/main/mod.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/main/skipped_analysis.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/mod.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/models.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/tests.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/types.rs diff --git a/crates/sqlbuild-analysis/src/analysis_route/constants.rs b/crates/sqlbuild-analysis/src/analysis_route/constants.rs new file mode 100644 index 0000000000..947c28e480 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/constants.rs @@ -0,0 +1,8 @@ +//! The native dialects and the diagnostic a model the native parser cannot read takes. + +use crate::analysis_route::types::NativeDialect; + +/// Each dialect, by its adapter's analysis dialect name, whose SQL a native module analyses. +pub(crate) const NATIVE_DIALECTS: &[(&str, &dyn NativeDialect)] = &[]; +/// The warning, or under `require_sql_analysis` the error, for a model analysis skips. +pub(crate) const SKIPPED_ANALYSIS_CODE: &str = "P026"; diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/analysis_route.rs b/crates/sqlbuild-analysis/src/analysis_route/main/analysis_route.rs new file mode 100644 index 0000000000..3c0122acd3 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/main/analysis_route.rs @@ -0,0 +1,12 @@ +use crate::analysis_route::constants::NATIVE_DIALECTS; +use crate::analysis_route::models::AnalysisRoute; + +/// The route of `dialect`, an adapter's analysis dialect name; unlisted dialects stay on Polyglot. +#[must_use] +pub fn analysis_route(dialect: Option<&str>) -> AnalysisRoute { + dialect + .and_then(|name| NATIVE_DIALECTS.iter().find(|(native, _)| *native == name)) + .map_or(AnalysisRoute::Legacy, |(_, module)| { + AnalysisRoute::Native(*module) + }) +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs b/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs new file mode 100644 index 0000000000..d04b006a9d --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs @@ -0,0 +1,3 @@ +pub mod analysis_route; +pub mod native_gap; +pub mod skipped_analysis; diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs b/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs new file mode 100644 index 0000000000..11f871be87 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use sqlbuild_sqltext::compiler::main::placeholder_defaults::placeholder_defaults; + +use crate::analysis_route::models::{LocatedGap, ParserGap}; +use crate::analysis_route::types::NativeDialect; +use crate::assembly::project::models::SyntaxCheck; +use crate::semantic_validation::main::normalize::normalize_analysis_sql; +use crate::semantic_validation::models::NormalizationInput; + +/// The first gap of the native parser in `check`'s SQL, normalised as analysis reads it, with the +/// model's placeholder values filled; none when the parser reads it whole. +/// +/// # Errors +/// +/// The normalisation's message when the SQL cannot be normalised. +pub fn native_gap( + dialect: &dyn NativeDialect, + check: &SyntaxCheck, +) -> Result, String> { + let normalised: String = normalize_analysis_sql(NormalizationInput { + sql: check.sql.clone(), + dialect: dialect.name().to_owned(), + stubs: HashMap::new(), + placeholders: HashMap::new(), + })?; + let analysed: String = placeholder_defaults(&normalised, &check.placeholders); + Ok(dialect + .read_whole(&analysed) + .err() + .map(|gap| located(&analysed, gap))) +} + +/// The gap with the line and column of its offset; an offset past the end is the end. +fn located(sql: &str, gap: ParserGap) -> LocatedGap { + let before: &str = sql.get(..gap.offset).unwrap_or(sql); + let line_start: usize = before.rfind('\n').map_or(0, |newline| newline + 1); + LocatedGap { + construct: gap.construct, + line: before.matches('\n').count() + 1, + column: before[line_start..].chars().count() + 1, + } +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/skipped_analysis.rs b/crates/sqlbuild-analysis/src/analysis_route/main/skipped_analysis.rs new file mode 100644 index 0000000000..3fba7a616f --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/main/skipped_analysis.rs @@ -0,0 +1,15 @@ +use crate::analysis_route::constants::SKIPPED_ANALYSIS_CODE; +use crate::analysis_route::models::LocatedGap; + +/// P026's code and message for a model whose SQL the dialect's native parser cannot read. +#[must_use] +pub fn skipped_analysis(model: &str, dialect: &str, gap: &LocatedGap) -> (&'static str, String) { + ( + SKIPPED_ANALYSIS_CODE, + format!( + "SQL analysis skipped for model '{model}': the {dialect} parser cannot read {} at \ + line {}, column {}", + gap.construct, gap.line, gap.column + ), + ) +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/mod.rs b/crates/sqlbuild-analysis/src/analysis_route/mod.rs new file mode 100644 index 0000000000..76ff5d49a0 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/mod.rs @@ -0,0 +1,9 @@ +//! Which engine analyses a dialect's SQL: the Polyglot path, or a native module over SQLQuack. + +pub(crate) mod constants; +pub mod main; +pub mod models; +pub mod types; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlbuild-analysis/src/analysis_route/models.rs b/crates/sqlbuild-analysis/src/analysis_route/models.rs new file mode 100644 index 0000000000..cfff517ab8 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/models.rs @@ -0,0 +1,27 @@ +//! The route a dialect's analysis takes, and what stops a native parser reading SQL whole. + +use crate::analysis_route::types::NativeDialect; + +/// The engine that analyses a dialect's SQL. +#[derive(Clone, Copy)] +pub enum AnalysisRoute { + /// Polyglot, as before the dialect's native module. + Legacy, + /// The dialect's native module; a model it cannot read whole is not analysed. + Native(&'static dyn NativeDialect), +} + +/// The first construct a native parser cannot read, and its byte offset in the SQL it read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParserGap { + pub construct: String, + pub offset: usize, +} + +/// A gap with the line and column, from 1, of its offset in the analysed SQL. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocatedGap { + pub construct: String, + pub line: usize, + pub column: usize, +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests.rs b/crates/sqlbuild-analysis/src/analysis_route/tests.rs new file mode 100644 index 0000000000..72200fd0e3 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_native_gap.rs"] +mod test_native_gap; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs b/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs new file mode 100644 index 0000000000..986ac59de8 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs @@ -0,0 +1,112 @@ +use crate::analysis_route::main::analysis_route::analysis_route; +use crate::analysis_route::main::native_gap::native_gap; +use crate::analysis_route::main::skipped_analysis::skipped_analysis; +use crate::analysis_route::models::{AnalysisRoute, LocatedGap, ParserGap}; +use crate::analysis_route::tests::test_types::NativeGapTestCase; +use crate::analysis_route::types::NativeDialect; +use crate::assembly::project::models::SyntaxCheck; + +/// A test dialect whose parser refuses the word PIVOT and an unfilled `@@@` placeholder. +struct PivotlessDialect; + +impl NativeDialect for PivotlessDialect { + fn name(&self) -> &'static str { + "postgres" + } + + fn read_whole(&self, sql: &str) -> Result<(), ParserGap> { + let refused = ["PIVOT", "@@@"] + .iter() + .filter_map(|word| sql.find(word).map(|offset| (offset, *word))) + .min(); + match refused { + Some((offset, word)) => Err(ParserGap { + construct: word.to_owned(), + offset, + }), + None => Ok(()), + } + } +} + +#[test] +fn given_any_dialect_when_routing_analysis_then_it_stays_on_the_legacy_path() { + for dialect in [ + Some("postgres"), + Some("snowflake"), + Some("duckdb"), + Some("sqlite"), + Some("bigquery"), + Some("databricks"), + Some("clickhouse"), + Some("tsql"), + None, + ] { + assert!( + matches!(analysis_route(dialect), AnalysisRoute::Legacy), + "{dialect:?}" + ); + } +} + +#[test] +fn given_model_sql_when_probing_the_native_parser_then_its_gap_is_located_after_placeholders() { + let test_cases = [ + NativeGapTestCase { + description: "SQL the parser reads whole has no gap", + sql: "SELECT 1 AS a", + placeholders: &[], + expected_gap: None, + }, + NativeGapTestCase { + description: "a placeholder is filled before the parser reads the SQL", + sql: "SELECT * FROM t WHERE region = @@@region", + placeholders: &[("region", "'eu'")], + expected_gap: None, + }, + NativeGapTestCase { + description: "the gap's line and column count from 1, in characters", + sql: "SELECT 'é' AS a\nFROM t\n PIVOT (sum(x) FOR y IN (1))", + placeholders: &[], + expected_gap: Some(LocatedGap { + construct: "PIVOT".to_owned(), + line: 3, + column: 3, + }), + }, + ]; + for test_case in test_cases { + let check = SyntaxCheck { + sql: test_case.sql.to_owned(), + placeholders: test_case + .placeholders + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(), + }; + assert_eq!( + native_gap(&PivotlessDialect, &check), + Ok(test_case.expected_gap), + "{}", + test_case.description + ); + } +} + +#[test] +fn given_a_located_gap_when_skipping_analysis_then_p026_names_the_model_and_the_gap() { + let gap = LocatedGap { + construct: "PIVOT".to_owned(), + line: 3, + column: 3, + }; + assert_eq!( + skipped_analysis("orders_pivot", "postgres", &gap), + ( + "P026", + "SQL analysis skipped for model 'orders_pivot': the postgres parser cannot read PIVOT \ + at line 3, column 3" + .to_owned() + ) + ); +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs b/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs new file mode 100644 index 0000000000..2cbc25869e --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs @@ -0,0 +1,8 @@ +use crate::analysis_route::models::LocatedGap; + +pub(super) struct NativeGapTestCase { + pub(super) description: &'static str, + pub(super) sql: &'static str, + pub(super) placeholders: &'static [(&'static str, &'static str)], + pub(super) expected_gap: Option, +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/types.rs b/crates/sqlbuild-analysis/src/analysis_route/types.rs new file mode 100644 index 0000000000..e8965a40c5 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/types.rs @@ -0,0 +1,16 @@ +//! What a dialect's native analysis module provides. + +use crate::analysis_route::models::ParserGap; + +/// A dialect whose SQL SQLQuack analyses in place of Polyglot. +pub trait NativeDialect: Sync { + /// The adapter's analysis dialect name, such as `postgres`, as diagnostics and normalisation read it. + fn name(&self) -> &'static str; + + /// Whether the native parser reads `sql` whole: one statement, no gap and no unparsed bytes. + /// + /// # Errors + /// + /// The first construct the parser cannot read, and its byte offset in `sql`. + fn read_whole(&self, sql: &str) -> Result<(), ParserGap>; +} diff --git a/crates/sqlbuild-analysis/src/lib.rs b/crates/sqlbuild-analysis/src/lib.rs index 173c375e46..2339caa1b3 100644 --- a/crates/sqlbuild-analysis/src/lib.rs +++ b/crates/sqlbuild-analysis/src/lib.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] +pub mod analysis_route; pub mod assembly; pub mod auditing; pub mod binding_locations; From 12473acad162d2cc50765841262d99bbdffd41f0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:28:49 +0000 Subject: [PATCH 2/2] feat(analysis): native route skips unreadable models and spells stubs the engine accepts A native route checks each model's analysis SQL and inline hook statements with its dialect's parser. A gap warns P026 and the model is not analysed; under require_sql_analysis it is an error. UDF and cursor stubs take the dialect's prefix, and a quoted hook parameter becomes the string literal it is filled with. On ClickHouse's native route, contract types are compared without top-level Nullable and LowCardinality, and nullability through the inferred flag. Every dialect stays on the Polyglot route until its native module is registered. --- .../src/analysis_route/constants.rs | 4 + .../main/clickhouse_type_match.rs | 51 ++++++ .../src/analysis_route/main/mod.rs | 1 + .../src/analysis_route/main/native_gap.rs | 28 ++-- .../src/analysis_route/models.rs | 8 + .../src/analysis_route/tests.rs | 2 + .../tests/test_clickhouse_type_match.rs | 92 +++++++++++ .../analysis_route/tests/test_native_gap.rs | 153 +++++++++++------- .../src/analysis_route/tests/test_types.rs | 53 +++++- .../src/analysis_route/types.rs | 9 +- .../src/compiled_project/_helpers/plain.rs | 1 + .../src/compiled_project/models.rs | 4 + .../src/contracts/_helpers/columns.rs | 5 +- .../src/contracts/_helpers/type_comparison.rs | 37 ++++- .../src/contracts/constants.rs | 2 + .../_helpers/normalization.rs | 38 ++++- .../semantic_validation/_helpers/positions.rs | 8 +- .../src/semantic_validation/main.rs | 1 + .../main/normalize_native.rs | 8 + .../compile_attachment/model_inputs.rs | 1 + .../_helpers/compile_driver/project.rs | 35 ++-- .../_helpers/compile_records/constants.rs | 1 + .../_helpers/compiled_project/project.rs | 1 + .../_helpers/input_loops/model_loop.rs | 35 ++-- .../_helpers/input_loops/model_loop_host.rs | 3 + .../input_loops/model_loop_opt_out.rs | 102 +++++++++++- .../_helpers/lineage/project_lineage.rs | 21 ++- .../bindings/_helpers/scopes/scope_loading.rs | 22 +-- .../src/engine/_helpers/project_rows.rs | 1 + .../src/engine/_helpers/rows.rs | 1 + .../src/engine/tests/test_evaluation.rs | 60 +++++++ .../src/engine/tests/test_types.rs | 7 + crates/sqlbuild-rules/src/models.rs | 2 + fensu.toml | 6 +- src/sqlbuild/_native.pyi | 2 + 35 files changed, 669 insertions(+), 136 deletions(-) create mode 100644 crates/sqlbuild-analysis/src/analysis_route/main/clickhouse_type_match.rs create mode 100644 crates/sqlbuild-analysis/src/analysis_route/tests/test_clickhouse_type_match.rs create mode 100644 crates/sqlbuild-analysis/src/semantic_validation/main/normalize_native.rs diff --git a/crates/sqlbuild-analysis/src/analysis_route/constants.rs b/crates/sqlbuild-analysis/src/analysis_route/constants.rs index 947c28e480..1e31d63436 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/constants.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/constants.rs @@ -6,3 +6,7 @@ use crate::analysis_route::types::NativeDialect; pub(crate) const NATIVE_DIALECTS: &[(&str, &dyn NativeDialect)] = &[]; /// The warning, or under `require_sql_analysis` the error, for a model analysis skips. pub(crate) const SKIPPED_ANALYSIS_CODE: &str = "P026"; +/// ClickHouse's top-level nullability wrapper, which the native profile spells as a flag. +pub(crate) const NULLABLE_WRAPPER: &str = "Nullable"; +/// ClickHouse's top-level dictionary-encoding wrapper, which leaves the column's type as it is. +pub(crate) const LOW_CARDINALITY_WRAPPER: &str = "LowCardinality"; diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/clickhouse_type_match.rs b/crates/sqlbuild-analysis/src/analysis_route/main/clickhouse_type_match.rs new file mode 100644 index 0000000000..91223ddd45 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/main/clickhouse_type_match.rs @@ -0,0 +1,51 @@ +use crate::analysis_route::constants::{LOW_CARDINALITY_WRAPPER, NULLABLE_WRAPPER}; +use crate::analysis_route::models::TypeMatch; + +/// A declared and an inferred ClickHouse type compared without top-level wrappers, nullability apart. +#[must_use] +pub fn clickhouse_type_match(declared: &str, inferred: &str, inferred_nullable: bool) -> TypeMatch { + let (declared_base, declared_nullable) = unwrapped(declared); + let (inferred_base, wrapped_nullable) = unwrapped(inferred); + TypeMatch { + same_type: declared_base == inferred_base, + nullable_into_non_null: !declared_nullable && (inferred_nullable || wrapped_nullable), + } +} + +/// The type inside its top-level `Nullable` and `LowCardinality` wrappers, and whether it is nullable. +fn unwrapped(type_sql: &str) -> (&str, bool) { + let mut current: &str = type_sql.trim(); + let mut nullable: bool = false; + loop { + if let Some(inner) = wrapped(current, NULLABLE_WRAPPER) { + nullable = true; + current = inner; + } else if let Some(inner) = wrapped(current, LOW_CARDINALITY_WRAPPER) { + current = inner; + } else { + return (current, nullable); + } + } +} + +/// The argument of `wrapper(...)` when the whole of `type_sql` is that one call. +fn wrapped<'a>(type_sql: &'a str, wrapper: &str) -> Option<&'a str> { + let head: &str = type_sql.get(..wrapper.len())?; + if !head.eq_ignore_ascii_case(wrapper) { + return None; + } + let inner: &str = type_sql[wrapper.len()..] + .trim_start() + .strip_prefix('(')? + .strip_suffix(')')?; + let mut depth: usize = 0; + for character in inner.chars() { + match character { + '(' => depth += 1, + ')' if depth == 0 => return None, + ')' => depth -= 1, + _ => {} + } + } + Some(inner.trim()) +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs b/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs index d04b006a9d..87c5c0cfd3 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/main/mod.rs @@ -1,3 +1,4 @@ pub mod analysis_route; +pub mod clickhouse_type_match; pub mod native_gap; pub mod skipped_analysis; diff --git a/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs b/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs index 11f871be87..2d2c02726f 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs @@ -1,29 +1,19 @@ -use std::collections::HashMap; - use sqlbuild_sqltext::compiler::main::placeholder_defaults::placeholder_defaults; use crate::analysis_route::models::{LocatedGap, ParserGap}; use crate::analysis_route::types::NativeDialect; use crate::assembly::project::models::SyntaxCheck; -use crate::semantic_validation::main::normalize::normalize_analysis_sql; -use crate::semantic_validation::models::NormalizationInput; +use crate::compiled_project::main::text_position::text_position; +use crate::compiled_project::models::TextPosition; +use crate::semantic_validation::main::normalize_native::normalize_native_sql; -/// The first gap of the native parser in `check`'s SQL, normalised as analysis reads it, with the -/// model's placeholder values filled; none when the parser reads it whole. -/// -/// # Errors -/// -/// The normalisation's message when the SQL cannot be normalised. +/// The native parser's first gap in `check`'s analysis SQL, with the dialect's stub spelling. pub fn native_gap( dialect: &dyn NativeDialect, check: &SyntaxCheck, ) -> Result, String> { - let normalised: String = normalize_analysis_sql(NormalizationInput { - sql: check.sql.clone(), - dialect: dialect.name().to_owned(), - stubs: HashMap::new(), - placeholders: HashMap::new(), - })?; + let normalised: String = + normalize_native_sql(&check.sql, dialect.name(), dialect.stub_prefix())?; let analysed: String = placeholder_defaults(&normalised, &check.placeholders); Ok(dialect .read_whole(&analysed) @@ -34,10 +24,10 @@ pub fn native_gap( /// The gap with the line and column of its offset; an offset past the end is the end. fn located(sql: &str, gap: ParserGap) -> LocatedGap { let before: &str = sql.get(..gap.offset).unwrap_or(sql); - let line_start: usize = before.rfind('\n').map_or(0, |newline| newline + 1); + let position: TextPosition = text_position(sql, before.chars().count()); LocatedGap { construct: gap.construct, - line: before.matches('\n').count() + 1, - column: before[line_start..].chars().count() + 1, + line: position.line, + column: position.column, } } diff --git a/crates/sqlbuild-analysis/src/analysis_route/models.rs b/crates/sqlbuild-analysis/src/analysis_route/models.rs index cfff517ab8..a93a53d297 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/models.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/models.rs @@ -25,3 +25,11 @@ pub struct LocatedGap { pub line: usize, pub column: usize, } + +/// How an inferred column type meets its declared type: the type itself, then its nullability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TypeMatch { + pub same_type: bool, + /// The inferred column may be null, but its declared type does not allow it. + pub nullable_into_non_null: bool, +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests.rs b/crates/sqlbuild-analysis/src/analysis_route/tests.rs index 72200fd0e3..b8464bdb55 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/tests.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/tests.rs @@ -1,3 +1,5 @@ +#[path = "tests/test_clickhouse_type_match.rs"] +mod test_clickhouse_type_match; #[path = "tests/test_native_gap.rs"] mod test_native_gap; #[path = "tests/test_types.rs"] diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests/test_clickhouse_type_match.rs b/crates/sqlbuild-analysis/src/analysis_route/tests/test_clickhouse_type_match.rs new file mode 100644 index 0000000000..e23bab65e4 --- /dev/null +++ b/crates/sqlbuild-analysis/src/analysis_route/tests/test_clickhouse_type_match.rs @@ -0,0 +1,92 @@ +use crate::analysis_route::main::clickhouse_type_match::clickhouse_type_match; +use crate::analysis_route::models::TypeMatch; +use crate::analysis_route::tests::test_types::TypeMatchTestCase; + +#[test] +fn given_clickhouse_column_types_when_matching_then_wrappers_are_stripped_and_nullability_compared_apart() + { + let test_cases = [ + TypeMatchTestCase { + description: "a nullable String flag matches DESCRIBE's Nullable(String)", + declared: "Nullable(String)", + inferred: "String", + inferred_nullable: true, + expected_match: TypeMatch { + same_type: true, + nullable_into_non_null: false, + }, + }, + TypeMatchTestCase { + description: "a declared LowCardinality(String) is a String", + declared: "LowCardinality(String)", + inferred: "String", + inferred_nullable: false, + expected_match: TypeMatch { + same_type: true, + nullable_into_non_null: false, + }, + }, + TypeMatchTestCase { + description: "LowCardinality around Nullable allows null", + declared: "LowCardinality(Nullable(String))", + inferred: "Nullable(String)", + inferred_nullable: false, + expected_match: TypeMatch { + same_type: true, + nullable_into_non_null: false, + }, + }, + TypeMatchTestCase { + description: "a nullable inference into a plain String declaration is a nullability conflict", + declared: "String", + inferred: "String", + inferred_nullable: true, + expected_match: TypeMatch { + same_type: true, + nullable_into_non_null: true, + }, + }, + TypeMatchTestCase { + description: "a non-null inference into a Nullable declaration is no conflict", + declared: "Nullable(Int64)", + inferred: "Int64", + inferred_nullable: false, + expected_match: TypeMatch { + same_type: true, + nullable_into_non_null: false, + }, + }, + TypeMatchTestCase { + description: "different base types differ whatever their wrappers", + declared: "Nullable(Int32)", + inferred: "Int64", + inferred_nullable: true, + expected_match: TypeMatch { + same_type: false, + nullable_into_non_null: false, + }, + }, + TypeMatchTestCase { + description: "a Nullable nested inside Array is part of the type, not stripped", + declared: "Array(Nullable(String))", + inferred: "Array(String)", + inferred_nullable: false, + expected_match: TypeMatch { + same_type: false, + nullable_into_non_null: false, + }, + }, + ]; + for test_case in test_cases { + assert_eq!( + clickhouse_type_match( + test_case.declared, + test_case.inferred, + test_case.inferred_nullable + ), + test_case.expected_match, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs b/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs index 986ac59de8..75a1600999 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/tests/test_native_gap.rs @@ -1,50 +1,67 @@ use crate::analysis_route::main::analysis_route::analysis_route; use crate::analysis_route::main::native_gap::native_gap; use crate::analysis_route::main::skipped_analysis::skipped_analysis; -use crate::analysis_route::models::{AnalysisRoute, LocatedGap, ParserGap}; -use crate::analysis_route::tests::test_types::NativeGapTestCase; -use crate::analysis_route::types::NativeDialect; +use crate::analysis_route::models::{AnalysisRoute, LocatedGap}; +use crate::analysis_route::tests::test_types::{ + NativeGapTestCase, PivotlessDialect, RouteTestCase, SkippedAnalysisTestCase, +}; use crate::assembly::project::models::SyntaxCheck; -/// A test dialect whose parser refuses the word PIVOT and an unfilled `@@@` placeholder. -struct PivotlessDialect; - -impl NativeDialect for PivotlessDialect { - fn name(&self) -> &'static str { - "postgres" - } - - fn read_whole(&self, sql: &str) -> Result<(), ParserGap> { - let refused = ["PIVOT", "@@@"] - .iter() - .filter_map(|word| sql.find(word).map(|offset| (offset, *word))) - .min(); - match refused { - Some((offset, word)) => Err(ParserGap { - construct: word.to_owned(), - offset, - }), - None => Ok(()), - } - } -} - #[test] fn given_any_dialect_when_routing_analysis_then_it_stays_on_the_legacy_path() { - for dialect in [ - Some("postgres"), - Some("snowflake"), - Some("duckdb"), - Some("sqlite"), - Some("bigquery"), - Some("databricks"), - Some("clickhouse"), - Some("tsql"), - None, - ] { - assert!( - matches!(analysis_route(dialect), AnalysisRoute::Legacy), - "{dialect:?}" + let test_cases = [ + RouteTestCase { + description: "postgres", + dialect: Some("postgres"), + expected_legacy: true, + }, + RouteTestCase { + description: "snowflake", + dialect: Some("snowflake"), + expected_legacy: true, + }, + RouteTestCase { + description: "duckdb", + dialect: Some("duckdb"), + expected_legacy: true, + }, + RouteTestCase { + description: "sqlite", + dialect: Some("sqlite"), + expected_legacy: true, + }, + RouteTestCase { + description: "bigquery", + dialect: Some("bigquery"), + expected_legacy: true, + }, + RouteTestCase { + description: "databricks", + dialect: Some("databricks"), + expected_legacy: true, + }, + RouteTestCase { + description: "clickhouse", + dialect: Some("clickhouse"), + expected_legacy: true, + }, + RouteTestCase { + description: "tsql", + dialect: Some("tsql"), + expected_legacy: true, + }, + RouteTestCase { + description: "no analysis dialect", + dialect: None, + expected_legacy: true, + }, + ]; + for test_case in test_cases { + assert_eq!( + matches!(analysis_route(test_case.dialect), AnalysisRoute::Legacy), + test_case.expected_legacy, + "{}", + test_case.description ); } } @@ -64,6 +81,24 @@ fn given_model_sql_when_probing_the_native_parser_then_its_gap_is_located_after_ placeholders: &[("region", "'eu'")], expected_gap: None, }, + NativeGapTestCase { + description: "a UDF call's stub is spelled as the dialect accepts it", + sql: "SELECT __udf(\"scaled_amount\")(amount, 2) AS scaled FROM t", + placeholders: &[], + expected_gap: None, + }, + NativeGapTestCase { + description: "cursor bounds are stubs spelled as the dialect accepts them", + sql: "SELECT * FROM t WHERE at >= __cursor_start() AND at < __cursor_end( )", + placeholders: &[], + expected_gap: None, + }, + NativeGapTestCase { + description: "a quoted hook parameter becomes the string literal it is filled with", + sql: "SELECT @'relation' AS refreshed_relation, @'label' AS refresh_label", + placeholders: &[], + expected_gap: None, + }, NativeGapTestCase { description: "the gap's line and column count from 1, in characters", sql: "SELECT 'é' AS a\nFROM t\n PIVOT (sum(x) FOR y IN (1))", @@ -95,18 +130,28 @@ fn given_model_sql_when_probing_the_native_parser_then_its_gap_is_located_after_ #[test] fn given_a_located_gap_when_skipping_analysis_then_p026_names_the_model_and_the_gap() { - let gap = LocatedGap { - construct: "PIVOT".to_owned(), - line: 3, - column: 3, - }; - assert_eq!( - skipped_analysis("orders_pivot", "postgres", &gap), - ( - "P026", - "SQL analysis skipped for model 'orders_pivot': the postgres parser cannot read PIVOT \ - at line 3, column 3" - .to_owned() - ) - ); + let test_cases = [SkippedAnalysisTestCase { + description: "a PIVOT gap on line 3", + model: "orders_pivot", + dialect: "postgres", + gap: LocatedGap { + construct: "PIVOT".to_owned(), + line: 3, + column: 3, + }, + expected_code: "P026", + expected_message: "SQL analysis skipped for model 'orders_pivot': the postgres parser \ + cannot read PIVOT at line 3, column 3", + }]; + for test_case in test_cases { + assert_eq!( + skipped_analysis(test_case.model, test_case.dialect, &test_case.gap), + ( + test_case.expected_code, + test_case.expected_message.to_owned() + ), + "{}", + test_case.description + ); + } } diff --git a/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs b/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs index 2cbc25869e..9587ebda22 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/tests/test_types.rs @@ -1,4 +1,5 @@ -use crate::analysis_route::models::LocatedGap; +use crate::analysis_route::models::{LocatedGap, ParserGap, TypeMatch}; +use crate::analysis_route::types::NativeDialect; pub(super) struct NativeGapTestCase { pub(super) description: &'static str, @@ -6,3 +7,53 @@ pub(super) struct NativeGapTestCase { pub(super) placeholders: &'static [(&'static str, &'static str)], pub(super) expected_gap: Option, } + +pub(super) struct RouteTestCase { + pub(super) description: &'static str, + pub(super) dialect: Option<&'static str>, + pub(super) expected_legacy: bool, +} + +pub(super) struct SkippedAnalysisTestCase { + pub(super) description: &'static str, + pub(super) model: &'static str, + pub(super) dialect: &'static str, + pub(super) gap: LocatedGap, + pub(super) expected_code: &'static str, + pub(super) expected_message: &'static str, +} + +/// A test dialect whose parser refuses PIVOT, unfilled placeholders and one-part stubs. +pub(super) struct PivotlessDialect; + +impl NativeDialect for PivotlessDialect { + fn name(&self) -> &'static str { + "postgres" + } + + fn read_whole(&self, sql: &str) -> Result<(), ParserGap> { + let refused = ["PIVOT", "@@@", "__sqlbuild_udf_", "__cursor_", "@'"] + .iter() + .filter_map(|word| sql.find(word).map(|offset| (offset, *word))) + .min(); + match refused { + Some((offset, word)) => Err(ParserGap { + construct: word.to_owned(), + offset, + }), + None => Ok(()), + } + } + + fn stub_prefix(&self) -> &'static str { + "__sqlbuild." + } +} + +pub(super) struct TypeMatchTestCase { + pub(super) description: &'static str, + pub(super) declared: &'static str, + pub(super) inferred: &'static str, + pub(super) inferred_nullable: bool, + pub(super) expected_match: TypeMatch, +} diff --git a/crates/sqlbuild-analysis/src/analysis_route/types.rs b/crates/sqlbuild-analysis/src/analysis_route/types.rs index e8965a40c5..fedc80ad24 100644 --- a/crates/sqlbuild-analysis/src/analysis_route/types.rs +++ b/crates/sqlbuild-analysis/src/analysis_route/types.rs @@ -4,13 +4,12 @@ use crate::analysis_route::models::ParserGap; /// A dialect whose SQL SQLQuack analyses in place of Polyglot. pub trait NativeDialect: Sync { - /// The adapter's analysis dialect name, such as `postgres`, as diagnostics and normalisation read it. + /// The adapter's analysis dialect name, such as `postgres`. fn name(&self) -> &'static str; /// Whether the native parser reads `sql` whole: one statement, no gap and no unparsed bytes. - /// - /// # Errors - /// - /// The first construct the parser cannot read, and its byte offset in `sql`. fn read_whole(&self, sql: &str) -> Result<(), ParserGap>; + + /// The engine-accepted prefix of UDF and cursor stubs: `__sqlbuild_`, or `__sqlbuild.` for T-SQL. + fn stub_prefix(&self) -> &'static str; } diff --git a/crates/sqlbuild-analysis/src/compiled_project/_helpers/plain.rs b/crates/sqlbuild-analysis/src/compiled_project/_helpers/plain.rs index da86454d6c..24e36da682 100644 --- a/crates/sqlbuild-analysis/src/compiled_project/_helpers/plain.rs +++ b/crates/sqlbuild-analysis/src/compiled_project/_helpers/plain.rs @@ -41,5 +41,6 @@ pub(crate) fn plain_model_facts(model: PlainModel) -> CompiledModelFacts { analysis: None, typed_calls: model.typed_calls, expansion_passes: model.expansion_passes, + sql_analysis_skipped: model.sql_analysis_skipped, } } diff --git a/crates/sqlbuild-analysis/src/compiled_project/models.rs b/crates/sqlbuild-analysis/src/compiled_project/models.rs index 455cceb3f5..8d32c02f26 100644 --- a/crates/sqlbuild-analysis/src/compiled_project/models.rs +++ b/crates/sqlbuild-analysis/src/compiled_project/models.rs @@ -37,6 +37,8 @@ pub struct CompiledModelFacts { pub typed_calls: Vec, /// The expansion passes from `authored_query_sql` to `query_sql`; empty when unknown. pub expansion_passes: Vec>, + /// Whether analysis was skipped because the dialect's native parser cannot read the model. + pub sql_analysis_skipped: bool, } /// A model's effective config values with sorted keys, as the rules request has always read them. @@ -222,6 +224,8 @@ pub struct PlainModel { pub typed_calls: Vec, /// The expansion passes from `authored_query_sql` to `query_sql`; empty when unknown. pub expansion_passes: Vec>, + /// Whether analysis was skipped because the dialect's native parser cannot read the model. + pub sql_analysis_skipped: bool, } /// A one-based line and column, counted in characters. diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs index da6625e7a1..a5273a5217 100644 --- a/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs @@ -103,7 +103,7 @@ pub(crate) fn model_contract_diagnostics( continue; }; if validate_declared_shape { - diagnostics.extend(nullability(index, declared, inferred)); + diagnostics.extend(nullability(types, index, declared, inferred)); } let Some(declared_type) = declared.declared_type.as_deref() else { continue; @@ -205,11 +205,12 @@ fn extra_columns( } fn nullability( + types: &TypeComparer<'_>, index: usize, declared: &DeclaredColumn, inferred: &InferredOutputColumn, ) -> Option { - if !declared.not_null || !inferred.nullable { + if !types.nullable_into_non_null(declared, inferred) { return None; } Some(ContractDiagnostic { diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs index b72f041818..e439b2d73e 100644 --- a/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs @@ -3,7 +3,11 @@ use std::cell::RefCell; use std::collections::HashMap; -use crate::contracts::constants::UPPER_FOLDING_DIALECT; +use crate::analysis_route::main::analysis_route::analysis_route; +use crate::analysis_route::main::clickhouse_type_match::clickhouse_type_match; +use crate::analysis_route::models::AnalysisRoute; +use crate::contracts::constants::{CLICKHOUSE_DIALECT, UPPER_FOLDING_DIALECT}; +use crate::contracts::models::{DeclaredColumn, InferredOutputColumn}; use crate::type_system::errors::TypeNormalizationError; use crate::type_system::main::normalize_type::normalize_type; use crate::type_system::models::{NormalizedType, TypeNormalization}; @@ -18,6 +22,8 @@ pub(crate) enum TypeComparison { /// One request's type comparisons, normalizing each type string once as Python's cache does. pub(crate) struct TypeComparer<'a> { dialect: &'a str, + /// ClickHouse on its native route, whose types are compared without top-level wrappers. + native_clickhouse: bool, normalized: RefCell>>, } @@ -30,6 +36,8 @@ impl<'a> TypeComparer<'a> { pub(crate) fn new(dialect: &'a str) -> Self { Self { dialect, + native_clickhouse: dialect == CLICKHOUSE_DIALECT + && matches!(analysis_route(Some(dialect)), AnalysisRoute::Native(_)), normalized: RefCell::new(HashMap::new()), } } @@ -40,6 +48,13 @@ impl<'a> TypeComparer<'a> { left: &str, right: &str, ) -> Result { + if self.native_clickhouse { + return Ok(if clickhouse_type_match(left, right, false).same_type { + TypeComparison::Equal + } else { + TypeComparison::Different + }); + } let left: NormalizedType = self.normalized(left)?; let right: NormalizedType = self.normalized(right)?; Ok(if left == right { @@ -49,6 +64,26 @@ impl<'a> TypeComparer<'a> { }) } + /// Whether a column that may be null meets a declaration that does not allow it. + pub(crate) fn nullable_into_non_null( + &self, + declared: &DeclaredColumn, + inferred: &InferredOutputColumn, + ) -> bool { + if declared.not_null && inferred.nullable { + return true; + } + let (true, Some(declared_type), Some(inferred_type)) = ( + self.native_clickhouse, + declared.declared_type.as_deref(), + inferred.inferred_type.as_deref(), + ) else { + return false; + }; + clickhouse_type_match(declared_type, inferred_type, inferred.nullable) + .nullable_into_non_null + } + fn normalized(&self, type_sql: &str) -> Result { if let Some(known) = self.normalized.borrow().get(type_sql) { return known.clone(); diff --git a/crates/sqlbuild-analysis/src/contracts/constants.rs b/crates/sqlbuild-analysis/src/contracts/constants.rs index c1edad4679..08cb6e0278 100644 --- a/crates/sqlbuild-analysis/src/contracts/constants.rs +++ b/crates/sqlbuild-analysis/src/contracts/constants.rs @@ -42,5 +42,7 @@ pub(crate) const PROMOTION_REMOVE_CONTRACT_HELP: &str = "or remove the model's e pub(crate) const SETTING_SNIPPET_INDENT: &str = " "; pub(crate) const ADDITIONAL_HELP_SEPARATOR: &str = "\n = help: "; +/// The analysis dialect name whose native route compares ClickHouse types without wrappers. +pub(crate) const CLICKHOUSE_DIALECT: &str = "clickhouse"; /// The dialect that folds unquoted identifiers to upper case. pub(crate) const UPPER_FOLDING_DIALECT: &str = "snowflake"; diff --git a/crates/sqlbuild-analysis/src/semantic_validation/_helpers/normalization.rs b/crates/sqlbuild-analysis/src/semantic_validation/_helpers/normalization.rs index f64dbe36ab..daaeed17d4 100644 --- a/crates/sqlbuild-analysis/src/semantic_validation/_helpers/normalization.rs +++ b/crates/sqlbuild-analysis/src/semantic_validation/_helpers/normalization.rs @@ -1,5 +1,6 @@ //! Analysis normalization with a character-offset provenance map. +use crate::semantic_checks::constants::UDF_NAME_PREFIX; use regex::Regex; use sqlbuild_sqltext::sql_scan::main::matching_paren::matching_paren; use sqlbuild_sqltext::sql_scan::main::non_code_end::non_code_end; @@ -9,6 +10,8 @@ use std::sync::LazyLock; const DBT_REF: &str = "dbt_ref"; const UDF: &str = "udf"; +/// A hook parameter `@'name'` becomes a string literal with this prefix, as SQLBuild fills it. +const HOOK_PARAMETER_PREFIX: &str = "__sqlbuild_parameter__"; /// Snowflake's second line comment prefix; only Snowflake SQL reaches the variant scan. const SNOWFLAKE_LINE_COMMENT: &str = "//"; type Edit = (usize, usize, MappedSql); @@ -20,6 +23,10 @@ static FUNCTIONS: LazyLock> = LazyLock::new(|| { Regex::new(r#"__(udf|table_fn)\("([A-Za-z_][A-Za-z0-9_]*)"\)\s*"#) .map_err(|error| error.to_string()) }); +static NATIVE_STUBS: LazyLock> = LazyLock::new(|| { + Regex::new(r"__cursor_(start|end)\(\s*\)|@'([A-Za-z_][A-Za-z0-9_]*)'") + .map_err(|error| error.to_string()) +}); static PLACEHOLDERS: LazyLock> = LazyLock::new(|| Regex::new(r"@@@(\w+)").map_err(|error| error.to_string())); static VARIANT_BASE: LazyLock> = LazyLock::new(|| { @@ -85,8 +92,8 @@ impl MappedSql { pub(super) fn normalize( sql: &str, dialect: &str, - stubs: &HashMap, - placeholders: &HashMap, + (stubs, placeholders): (&HashMap, &HashMap), + udf_prefix: &str, ) -> Result { let mut result = MappedSql::new(sql); let mut edits: Vec = Vec::new(); @@ -125,7 +132,7 @@ pub(super) fn normalize( let name = captures.get(2).ok_or("missing function name")?; let mut end = whole.end(); let stub = if &captures[1] == UDF { - format!("__sqlbuild_udf_{}", name.as_str()) + format!("{udf_prefix}{}", name.as_str()) } else { end = matching_paren(result.sql.as_bytes(), whole.end(), QuotePolicy::COMPILER) .map_err(|error| format!("invalid table-function analysis input: {error:?}"))? @@ -174,7 +181,30 @@ pub(crate) fn normalize_analysis_sql( stubs: HashMap, placeholders: HashMap, ) -> Result { - normalize(sql, dialect, &stubs, &placeholders).map(|mapped| mapped.sql) + normalize(sql, dialect, (&stubs, &placeholders), UDF_NAME_PREFIX).map(|mapped| mapped.sql) +} + +/// Analysis SQL as a native parser reads it, with every stub spelled under `stub_prefix`. +pub(crate) fn normalize_native_sql( + sql: &str, + dialect: &str, + stub_prefix: &str, +) -> Result { + let none: HashMap = HashMap::new(); + let udf_prefix: String = format!("{stub_prefix}udf_"); + let normalized: String = normalize(sql, dialect, (&none, &none), &udf_prefix)?.sql; + let stubs: &Regex = NATIVE_STUBS.as_ref()?; + Ok(stubs + .replace_all(&normalized, |captures: ®ex::Captures<'_>| { + match (captures.get(1), captures.get(2)) { + (Some(bound), _) => format!("{stub_prefix}cursor_{}()", bound.as_str()), + (None, Some(parameter)) => { + format!("'{HOOK_PARAMETER_PREFIX}{}'", parameter.as_str()) + } + (None, None) => captures[0].to_owned(), + } + }) + .into_owned()) } pub(crate) fn normalize_dialect_sql_mapped( diff --git a/crates/sqlbuild-analysis/src/semantic_validation/_helpers/positions.rs b/crates/sqlbuild-analysis/src/semantic_validation/_helpers/positions.rs index 0cf001abe2..9afcdcfa07 100644 --- a/crates/sqlbuild-analysis/src/semantic_validation/_helpers/positions.rs +++ b/crates/sqlbuild-analysis/src/semantic_validation/_helpers/positions.rs @@ -1,5 +1,6 @@ //! Native authored positions, including macro-expansion passes. +use crate::semantic_checks::constants::UDF_NAME_PREFIX; use crate::semantic_validation::_helpers::diagnostics::column_pattern; use crate::semantic_validation::_helpers::normalization::normalize; use crate::semantic_validation::models::{BindingPositions, Expansion, PositionInput}; @@ -20,7 +21,12 @@ impl BindingPositions { cleaned, passes, } = request; - let mapped = normalize(&expanded, "snowflake", &HashMap::new(), &HashMap::new()); + let mapped = normalize( + &expanded, + "snowflake", + (&HashMap::new(), &HashMap::new()), + UDF_NAME_PREFIX, + ); let offsets = match mapped { Ok(mapped) if mapped.sql == cleaned => mapped.offsets, _ if expanded == cleaned => (0..=expanded.chars().count()).collect(), diff --git a/crates/sqlbuild-analysis/src/semantic_validation/main.rs b/crates/sqlbuild-analysis/src/semantic_validation/main.rs index a5d638eb14..930655bff7 100644 --- a/crates/sqlbuild-analysis/src/semantic_validation/main.rs +++ b/crates/sqlbuild-analysis/src/semantic_validation/main.rs @@ -7,6 +7,7 @@ pub mod normalize; pub mod normalize_batch; pub mod normalize_dialect; pub mod normalize_mapped; +pub mod normalize_native; pub mod truth_test_spans; use crate::semantic_validation::models::ProjectCatalog; diff --git a/crates/sqlbuild-analysis/src/semantic_validation/main/normalize_native.rs b/crates/sqlbuild-analysis/src/semantic_validation/main/normalize_native.rs new file mode 100644 index 0000000000..96928a47e6 --- /dev/null +++ b/crates/sqlbuild-analysis/src/semantic_validation/main/normalize_native.rs @@ -0,0 +1,8 @@ +/// Analysis SQL as a native parser reads it, with every stub spelled under `stub_prefix`. +pub fn normalize_native_sql(sql: &str, dialect: &str, stub_prefix: &str) -> Result { + crate::semantic_validation::_helpers::normalization::normalize_native_sql( + sql, + dialect, + stub_prefix, + ) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/compile_attachment/model_inputs.rs b/crates/sqlbuild-python/src/bindings/_helpers/compile_attachment/model_inputs.rs index d8a3636880..c42073aaf1 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/compile_attachment/model_inputs.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/compile_attachment/model_inputs.rs @@ -282,6 +282,7 @@ pub(crate) fn build_model_inputs<'py>( .extract()?, }, project_config_path.str()?.extract()?, + native_field(context, "sql_analysis_dialect")?.extract()?, ), ) .and_then(|value| pyo3::Bound::new(py, value)) diff --git a/crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs b/crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs index 4d298aaa6f..cb0e58f3e3 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs @@ -259,20 +259,19 @@ fn targeted_project<'py>( ) } -/// The compile inputs produced through the `COMPILE_PROJECT_INPUTS` frontier. -fn frontier_compile_inputs<'py>( - py: Python<'py>, - request: &ProjectRequest, - no_cache: bool, +/// The adapter behaviour compile inputs need, from the discovered project and its adapter. +pub(crate) fn compile_adapter_context<'py>( + discovered: &Bound<'py, PyAny>, + adapter: &Bound<'py, PyAny>, + syntax: &Bound<'py, PyAny>, ) -> PyResult> { - let discovered: &Bound<'py, PyAny> = request.discovered_inputs.bind(py); - let adapter: &Bound<'py, PyAny> = request.adapter.bind(py); + let py: Python<'py> = discovered.py(); let collection_rendering: Bound<'py, PyAny> = resolve_effective_collection_rendering( &native_field(discovered, "project_config")?, &py.None().into_bound(py), ) .and_then(|value| value.into_bound_py_any(py))?; - let adapter_context: Bound<'py, PyAny> = new_record_with( + new_record_with( py, RecordId::CompileAdapterContext, &[ @@ -282,9 +281,25 @@ fn frontier_compile_inputs<'py>( "python_functions_inherit_default_namespace", adapter.call_method0("python_functions_inherit_default_namespace")?, ), - ("sql_lexical_syntax", request.lexical_syntax(py)?), + ("sql_lexical_syntax", syntax.clone()), + ( + "sql_analysis_dialect", + adapter.call_method0("sql_analysis_dialect")?, + ), ], - )?; + ) +} + +/// The compile inputs produced through the `COMPILE_PROJECT_INPUTS` frontier. +fn frontier_compile_inputs<'py>( + py: Python<'py>, + request: &ProjectRequest, + no_cache: bool, +) -> PyResult> { + let discovered: &Bound<'py, PyAny> = request.discovered_inputs.bind(py); + let adapter: &Bound<'py, PyAny> = request.adapter.bind(py); + let adapter_context: Bound<'py, PyAny> = + compile_adapter_context(discovered, adapter, &request.lexical_syntax(py)?)?; let inputs: InputsRequest = InputsRequest { discovered_inputs: discovered.clone().unbind(), adapter_context: adapter_context.unbind(), diff --git a/crates/sqlbuild-python/src/bindings/_helpers/compile_records/constants.rs b/crates/sqlbuild-python/src/bindings/_helpers/compile_records/constants.rs index 15329c6c5b..b37e8646a4 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/compile_records/constants.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/compile_records/constants.rs @@ -154,6 +154,7 @@ const R_COMPILE_ADAPTER_CONTEXT: RecordSpec = RecordSpec { FieldSpec::required("collection_rendering"), FieldSpec::required("python_functions_inherit_default_namespace"), FieldSpec::required("sql_lexical_syntax"), + FieldSpec::new("sql_analysis_dialect", FieldDefault::None), ], }; const R_INVALID_SQL_REFERENCE_CALL: RecordSpec = RecordSpec { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/compiled_project/project.rs b/crates/sqlbuild-python/src/bindings/_helpers/compiled_project/project.rs index 8f3f6575fd..8bbd28d25f 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/compiled_project/project.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/compiled_project/project.rs @@ -588,6 +588,7 @@ pub(crate) fn model_facts(model: ModelInputPy<'_>) -> PyResult); /// The Python classes and error factory the loop builds usages, hooks and errors with. #[derive(FromPyObject)] @@ -200,6 +202,8 @@ pub(crate) struct NativeModelLoop { pub(crate) reserved_markers: Vec, pub(crate) settings: ModelLoopSettings, pub(crate) project_config_path: String, + /// Whether the dialect's SQL is analysed by Polyglot or by its native module. + pub(crate) route: AnalysisRoute, contract_classes: Py, loop_classes: Py, } @@ -246,6 +250,7 @@ impl NativeModelLoop { require_sql_analysis, }, project_config_path, + dialect, ) = settings; Python::attach(|py| index_sql_hook_definitions(loop_classes.bind(py)))?; Ok(Self { @@ -259,6 +264,7 @@ impl NativeModelLoop { require_sql_analysis, }, project_config_path, + route: analysis_route(dialect.as_deref()), contract_classes, loop_classes, }) @@ -707,7 +713,8 @@ impl NativeModelLoop { relative_path, contents, }; - let (analysed, rejected_opt_out) = self.analysis(context, &opt_out_model)?; + let (analysed, rejected_opt_out, analysis_skipped) = + self.analysis(context, &opt_out_model)?; let scanned = match extract_sql_references(&query_sql, &self.syntax) { ReferenceExtraction::Extracted(scanned) => scanned, ReferenceExtraction::Failed(failure) => { @@ -800,18 +807,20 @@ impl NativeModelLoop { references, declarations, analysis, + analysis_skipped, named_usages, macros: macros.map(|macros| macros.output), typed_calls, }) } - /// Whether the model's SQL is analysed and its rejected opt-out; syntax is checked unless deferred. + /// Whether the model's SQL is analysed, its rejected opt-out, and whether its parser skipped it. fn analysis<'py>( &self, context: &LoopContext<'_, 'py>, model: &OptOutModel<'_, 'py>, - ) -> PyResult<(bool, Option>)> { + ) -> PyResult<(bool, Option>, bool)> { + let mut skipped: bool = false; let (analysed, rejected) = match analysis_gate(self.settings, analysis_override(model.values)?) { AnalysisGate::Conflict => { @@ -821,16 +830,24 @@ impl NativeModelLoop { Some(ANALYSIS_CONFLICT_CODE), )); } - AnalysisGate::Enabled => (true, None), + AnalysisGate::Enabled => match self.route { + AnalysisRoute::Legacy => (true, None), + AnalysisRoute::Native(dialect) => { + let read: bool = skip_unreadable(self.settings, dialect, context, model)?; + skipped = !read; + (read, None) + } + }, AnalysisGate::Disabled => match rejected_opt_out(self, context, model)? { OptOut::Accepted => (false, None), OptOut::Rejected(location) => (true, Some(location)), }, }; - if analysed && !self.settings.defer_sql_validation { + let legacy: bool = matches!(self.route, AnalysisRoute::Legacy); + if analysed && legacy && !self.settings.defer_sql_validation { reject_unparseable_query(context, model)?; } - Ok((analysed, rejected)) + Ok((analysed, rejected, skipped)) } /// The query with canonical cursor intrinsics, or the message the attachment raises. diff --git a/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs b/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs index a8816208d0..3a15150222 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs @@ -102,6 +102,8 @@ pub(crate) struct FinishedModel<'py> { pub(crate) declarations: ExpandedDeclarations<'py>, /// Whether the SQL is analysed, and where a rejected `sql_analysis false` was authored. pub(crate) analysis: (bool, Option>), + /// Whether analysis was skipped because the dialect's native parser cannot read the model. + pub(crate) analysis_skipped: bool, /// The usages of the model's named schema and Python hooks, recorded after its hook usages. pub(crate) named_usages: Vec>, /// The query's macro expansion, when it calls macros. @@ -318,6 +320,7 @@ pub(crate) fn finished_record<'py>( constant_declarations: declarations(constants)?, typed_calls: finished.typed_calls, expansion_passes, + sql_analysis_skipped: finished.analysis_skipped, }); Ok((record, facts)) } diff --git a/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs b/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs index 435682527c..b392bc8aaf 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs @@ -1,11 +1,16 @@ -//! `rejected_sql_analysis_opt_out`: `sql_analysis false` on SQL that parses, when it is required. +//! Rejected `sql_analysis false` opt-outs, and native-route analysis skipped on a parser gap. use std::path::Path; use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyErr, PyResult, Python}; use pyo3::types::{PyBool, PyBoolMethods, PyDict, PyDictMethods, PyList, PyString, PyTuple}; +use sqlbuild_analysis::analysis_route::main::native_gap::native_gap; +use sqlbuild_analysis::analysis_route::main::skipped_analysis::skipped_analysis; +use sqlbuild_analysis::analysis_route::models::{AnalysisRoute, LocatedGap}; +use sqlbuild_analysis::analysis_route::types::NativeDialect; use sqlbuild_analysis::assembly::project::models::{SyntaxCheck, SyntaxMode}; use sqlbuild_analysis::compiled_project::main::opt_out_position::opt_out_position; +use sqlbuild_analysis::compiled_project::models::ModelLoopSettings; use sqlbuild_analysis::compiled_project::models::TextPosition; use sqlbuild_attachments::cursor_intrinsics::main::replace_intrinsics::replace_intrinsics; @@ -14,7 +19,9 @@ use crate::bindings::_helpers::input_loops::model_loop::{ }; use crate::bindings::_helpers::input_loops::model_loop_inputs::{HOOK_KEYS, present}; use crate::bindings::_helpers::project_assembly::resources::{syntax_error_message, syntax_valid}; +use crate::bindings::_helpers::records::construct::{new_helped_error, new_record_with}; use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::records::ids::{ErrorId, RecordId}; const OPT_OUT_KEYS: [&str; 2] = ["sql_analysis", "sql_validation"]; const CURSOR_TYPE_KEY: &str = "cursor_type"; @@ -33,6 +40,7 @@ const PATH_FIELD: &str = "path"; const LINE_FIELD: &str = "line"; const COLUMN_FIELD: &str = "column"; const HELP_ARGUMENT: &str = "help"; +const MODEL_RESOURCE: &str = "model"; /// Where the opt-out was authored, or a check the attachment still answers. pub(crate) enum OptOut<'py> { @@ -65,6 +73,75 @@ pub(crate) fn rejected_opt_out<'py>( { return Ok(OptOut::Accepted); } + let checks: Vec = model_checks(context, model)?; + let readable: bool = match model_loop.route { + AnalysisRoute::Legacy => syntax_valid(model.config.py(), GENERIC_DIALECT, &checks)?, + AnalysisRoute::Native(dialect) => first_gap(dialect, context, &checks)?.is_none(), + }; + if readable { + Ok(OptOut::Rejected(location(model_loop, context, model)?)) + } else { + Ok(OptOut::Accepted) + } +} + +/// Whether a native route analyses the model: P026 warns, or raises, when its parser cannot read it. +pub(crate) fn skip_unreadable( + settings: ModelLoopSettings, + dialect: &dyn NativeDialect, + context: &LoopContext<'_, '_>, + model: &OptOutModel<'_, '_>, +) -> PyResult { + let checks: Vec = model_checks(context, model)?; + let Some(gap) = first_gap(dialect, context, &checks)? else { + return Ok(true); + }; + let (code, message) = skipped_analysis(model.name, dialect.name(), &gap); + let py: Python<'_> = model.config.py(); + let help: &str = &context.classes.syntax_help; + if settings.require_sql_analysis { + return Err(new_helped_error( + py, + ErrorId::CompileInput, + (message, code), + Some(help), + )); + } + let diagnostic: Bound<'_, PyAny> = new_record_with( + py, + RecordId::CompilerDiagnostic, + &[ + ("phase", PyString::new(py, "compile").into_any()), + ("severity", PyString::new(py, "warning").into_any()), + ("code", PyString::new(py, code).into_any()), + ("message", PyString::new(py, &message).into_any()), + ( + "resource_type", + PyString::new(py, MODEL_RESOURCE).into_any(), + ), + ("resource_name", PyString::new(py, model.name).into_any()), + ( + "path", + context.classes.path_class.call1((model.relative_path,))?, + ), + ("help", PyString::new(py, help).into_any()), + ], + )?; + let arguments: Bound<'_, PyDict> = PyDict::new(py); + arguments.set_item("key", PyTuple::new(py, [code, model.name])?)?; + arguments.set_item("diagnostic", diagnostic)?; + context + .classes + .report_diagnostic + .call((), Some(&arguments))?; + Ok(false) +} + +/// The model's analysis SQL and each hook statement, as filled to run, with its placeholders. +fn model_checks( + context: &LoopContext<'_, '_>, + model: &OptOutModel<'_, '_>, +) -> PyResult> { let mut statements: Vec = vec![analysis_sql(context, model)?]; for key in HOOK_KEYS { if let Some(hooks) = model.values.get_item(key)? { @@ -72,18 +149,29 @@ pub(crate) fn rejected_opt_out<'py>( } } let placeholders: Vec<(String, String)> = placeholders(model.values)?; - let checks: Vec = statements + Ok(statements .into_iter() .map(|sql| SyntaxCheck { sql, placeholders: placeholders.clone(), }) - .collect(); - if syntax_valid(model.config.py(), GENERIC_DIALECT, &checks)? { - Ok(OptOut::Rejected(location(model_loop, context, model)?)) - } else { - Ok(OptOut::Accepted) + .collect()) +} + +/// The native parser's first gap across `checks`; none when it reads each one whole. +fn first_gap( + dialect: &dyn NativeDialect, + context: &LoopContext<'_, '_>, + checks: &[SyntaxCheck], +) -> PyResult> { + for check in checks { + if let Some(gap) = native_gap(dialect, check) + .map_err(|message| compile_error(context.classes, &message, None))? + { + return Ok(Some(gap)); + } } + Ok(None) } /// `validate_sql_syntax`: raise the model's SQL syntax error before its references are scanned. diff --git a/crates/sqlbuild-python/src/bindings/_helpers/lineage/project_lineage.rs b/crates/sqlbuild-python/src/bindings/_helpers/lineage/project_lineage.rs index 7920ac0b77..30e49894fc 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/lineage/project_lineage.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/lineage/project_lineage.rs @@ -7,6 +7,8 @@ use pyo3::prelude::{ }; use pyo3::types::{PyDict, PyDictMethods, PyTuple}; use pyo3::{pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::analysis_route::main::analysis_route::analysis_route; +use sqlbuild_analysis::analysis_route::models::AnalysisRoute; use sqlbuild_analysis::lineage::main::build_fast_lineage::build_fast_lineage; use sqlbuild_analysis::lineage::main::build_rich_lineage::build_rich_lineage; use sqlbuild_analysis::lineage::main::direct_semantic_uses::direct_semantic_uses; @@ -152,6 +154,8 @@ struct FastModel<'py> { /// A model's lineage facts: none, Python facts, or a compiled model's native facts and edge count. enum FastFacts<'py> { Missing, + /// No facts, on a native route, where Polyglot never parses the model: no lineage. + Skipped, Python(Bound<'py, PyAny>), Native(Bound<'py, PyAny>, usize), } @@ -165,6 +169,11 @@ fn fast_lineage<'py>( let py: Python<'py> = classes.py(); let mut work: Vec> = Vec::with_capacity(models.len()); let mut requests: Vec = Vec::new(); + let dialect_name: Option = dialect.map(|name| name.extract()).transpose()?; + let native: bool = matches!( + analysis_route(dialect_name.as_deref()), + AnalysisRoute::Native(_) + ); for model in models { let (has_star, star_resolved): (bool, bool) = star_flags(model)?; let expands_star: bool = has_star && !star_resolved; @@ -182,6 +191,15 @@ fn fast_lineage<'py>( continue; } let facts: Bound<'py, PyAny> = native_field(model, "fast_lineage_columns")?; + if facts.is_none() && native { + work.push(FastModel { + name: native_field(model, "name")?.extract()?, + facts: FastFacts::Skipped, + has_star, + expands_star, + }); + continue; + } if facts.is_none() { requests.push(FastLineageModel::Parse { query_sql: native_field(model, "query_sql")?.extract()?, @@ -214,7 +232,7 @@ fn fast_lineage<'py>( native_field(&binding_catalog, "native")?.extract()? }; let request = FastLineageRequest { - dialect: dialect.map(|name| name.extract()).transpose()?, + dialect: dialect_name, schema: fast_schema(project)?, models: requests, }; @@ -248,6 +266,7 @@ fn fast_lineage<'py>( } CompactColumns::Facts(columns.unbind()) } + FastFacts::Skipped => continue, FastFacts::Missing => { match outcomes.next() { Some(FastLineageOutcome::Built { columns, has_star }) => { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs b/crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs index 5259110f2a..1a509f5f27 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs @@ -14,13 +14,13 @@ use sqlbuild_host::entries::models::{AdapterHost, UserCodeHost}; use crate::bindings::_helpers::boundary::python_calls::call_with; use crate::bindings::_helpers::compile_driver::discovery::discover_project_inputs; use crate::bindings::_helpers::compile_driver::inputs::{InputsRequest, attached_inputs}; +use crate::bindings::_helpers::compile_driver::project::compile_adapter_context; use crate::bindings::_helpers::contracts::spec_resolution::resolve_effective_adapter_name; -use crate::bindings::_helpers::contracts::spec_resolution::resolve_effective_collection_rendering; use crate::bindings::_helpers::discovery::scope_snapshot::discover_scope_snapshot; use crate::bindings::_helpers::frontier::cache_directory::cache_directory; use crate::bindings::_helpers::frontier::compile_frontier::{FrontierStage, bridged, produced}; use crate::bindings::_helpers::native_host::host; -use crate::bindings::_helpers::records::construct::{new_record, new_record_with}; +use crate::bindings::_helpers::records::construct::new_record; use crate::bindings::_helpers::records::fields::field as native_field; use crate::bindings::_helpers::records::ids::RecordId; use crate::bindings::_helpers::scopes::scope_cache::{ @@ -78,23 +78,7 @@ fn compiled_index<'py>( syntax: &Bound<'py, PyAny>, ) -> PyResult> { let py: Python<'py> = discovered.py(); - let project_config: Bound<'py, PyAny> = native_field(&discovered, "project_config")?; - let collection_rendering: Bound<'py, PyAny> = - resolve_effective_collection_rendering(&project_config, &py.None().into_bound(py)) - .and_then(|value| value.into_bound_py_any(py))?; - let adapter_context: Bound<'py, PyAny> = new_record_with( - py, - RecordId::CompileAdapterContext, - &[ - ("value_renderer", adapter.clone()), - ("collection_rendering", collection_rendering), - ( - "python_functions_inherit_default_namespace", - adapter.call_method0("python_functions_inherit_default_namespace")?, - ), - ("sql_lexical_syntax", syntax.clone()), - ], - )?; + let adapter_context: Bound<'py, PyAny> = compile_adapter_context(&discovered, adapter, syntax)?; let inputs: InputsRequest = InputsRequest { discovered_inputs: discovered.unbind(), adapter_context: adapter_context.unbind(), diff --git a/crates/sqlbuild-rules/src/engine/_helpers/project_rows.rs b/crates/sqlbuild-rules/src/engine/_helpers/project_rows.rs index 0c6f68f4a4..46e814d073 100644 --- a/crates/sqlbuild-rules/src/engine/_helpers/project_rows.rs +++ b/crates/sqlbuild-rules/src/engine/_helpers/project_rows.rs @@ -64,6 +64,7 @@ pub(crate) fn model_row(facts: &CompiledModelFacts) -> Result .map(declaration) .collect(), typed_calls: facts.typed_calls.clone(), + sql_analysis_skipped: facts.sql_analysis_skipped, }) } diff --git a/crates/sqlbuild-rules/src/engine/_helpers/rows.rs b/crates/sqlbuild-rules/src/engine/_helpers/rows.rs index 3e47c5c188..c7a9a6bdee 100644 --- a/crates/sqlbuild-rules/src/engine/_helpers/rows.rs +++ b/crates/sqlbuild-rules/src/engine/_helpers/rows.rs @@ -63,6 +63,7 @@ fn build_model(row: ModelRow, inputs: &ModelInputs<'_, '_>) -> Result Result<(), String> { + let test_cases = [ + test_types::AnalysisSkipTestCase { + description: "an analysed model keeps its rules", + sql_analysis_skipped: false, + config: &[], + expected_disabled: false, + }, + test_types::AnalysisSkipTestCase { + description: "a model its dialect's native parser cannot read gets no rules", + sql_analysis_skipped: true, + config: &[], + expected_disabled: true, + }, + test_types::AnalysisSkipTestCase { + description: "a model that opts out with sql_analysis false gets no rules", + sql_analysis_skipped: false, + config: &[("sql_analysis", false)], + expected_disabled: true, + }, + ]; + for test_case in test_cases { + let mut row: ModelRow = ModelRow { + name: "orders".to_owned(), + relative_path: "models/orders.sql".to_owned(), + query_sql: "SELECT 1 AS id".to_owned(), + sql_analysis_skipped: test_case.sql_analysis_skipped, + ..ModelRow::default() + }; + row.config.extend( + test_case + .config + .iter() + .map(|(key, enabled)| ((*key).to_owned(), Value::Bool(*enabled))), + ); + let context: ModelRowsContext<'_> = ModelRowsContext { + sql_analysis_enabled: true, + include_type_proof: false, + dialect: "duckdb", + attached_audit_targets: &[], + sql_tests: &[], + }; + let models: Vec = build_models(vec![row], context, |_, _| Ok(false))?; + assert_eq!( + models + .iter() + .map(|model| model.sql_analysis_disabled) + .collect::>(), + vec![test_case.expected_disabled], + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlbuild-rules/src/engine/tests/test_types.rs b/crates/sqlbuild-rules/src/engine/tests/test_types.rs index fb3fd5bbea..24d53f6a12 100644 --- a/crates/sqlbuild-rules/src/engine/tests/test_types.rs +++ b/crates/sqlbuild-rules/src/engine/tests/test_types.rs @@ -323,3 +323,10 @@ pub(crate) struct DialectCallsTestCase { pub(crate) dialect: &'static str, pub(crate) expected_calls: &'static [&'static str], } + +pub(crate) struct AnalysisSkipTestCase { + pub(crate) description: &'static str, + pub(crate) sql_analysis_skipped: bool, + pub(crate) config: &'static [(&'static str, bool)], + pub(crate) expected_disabled: bool, +} diff --git a/crates/sqlbuild-rules/src/models.rs b/crates/sqlbuild-rules/src/models.rs index cf91da8c3a..099bcd3ae5 100644 --- a/crates/sqlbuild-rules/src/models.rs +++ b/crates/sqlbuild-rules/src/models.rs @@ -919,6 +919,8 @@ pub struct ModelRow { pub constant_declarations: Vec, /// Each expression SQL macro call in `query_sql`, by byte range. pub typed_calls: Vec, + /// Whether analysis was skipped because the dialect's native parser cannot read the model. + pub sql_analysis_skipped: bool, } /// A model's declared schema entry. diff --git a/fensu.toml b/fensu.toml index c66c31072f..bee7f18a71 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1956,7 +1956,7 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/pipeline_entries.rs=1", "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/plan_pipeline.rs=6", "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/plan_result.rs=7", - "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs=8", + "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/project.rs=9", "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/python_plan_entries.rs=5", "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/relation_targets.rs=7", "crates/sqlbuild-python/src/bindings/_helpers/compile_driver/selection_phases.rs=3", @@ -2130,7 +2130,7 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs=1", "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_macros.rs=6", "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_named.rs=5", - "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs=5", + "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_opt_out.rs=7", "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_python_hooks.rs=1", "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_schema.rs=1", "crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_visibility.rs=5", @@ -2399,7 +2399,7 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_command.rs=8", "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_errors.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_index.rs=32", - "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_loading.rs=2", "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_lookup.rs=8", "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_objects.rs=18", "crates/sqlbuild-python/src/bindings/_helpers/scopes/scope_report_objects.rs=23", diff --git a/src/sqlbuild/_native.pyi b/src/sqlbuild/_native.pyi index 9bba402174..f75bac9647 100644 --- a/src/sqlbuild/_native.pyi +++ b/src/sqlbuild/_native.pyi @@ -9156,12 +9156,14 @@ class CompileAdapterContext(NativeRecord): collection_rendering: CollectionRendering python_functions_inherit_default_namespace: bool sql_lexical_syntax: SqlLexicalSyntax + sql_analysis_dialect: str | None def __init__( self, value_renderer: TypedSqlValueRenderer, collection_rendering: CollectionRendering, python_functions_inherit_default_namespace: bool, sql_lexical_syntax: SqlLexicalSyntax, + sql_analysis_dialect: str | None = None, ) -> None: ... class InvalidSqlReferenceCall(NativeRecord):