Skip to content

Commit 12473ac

Browse files
committed
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.
1 parent a759b7c commit 12473ac

35 files changed

Lines changed: 669 additions & 136 deletions

File tree

‎crates/sqlbuild-analysis/src/analysis_route/constants.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ use crate::analysis_route::types::NativeDialect;
66
pub(crate) const NATIVE_DIALECTS: &[(&str, &dyn NativeDialect)] = &[];
77
/// The warning, or under `require_sql_analysis` the error, for a model analysis skips.
88
pub(crate) const SKIPPED_ANALYSIS_CODE: &str = "P026";
9+
/// ClickHouse's top-level nullability wrapper, which the native profile spells as a flag.
10+
pub(crate) const NULLABLE_WRAPPER: &str = "Nullable";
11+
/// ClickHouse's top-level dictionary-encoding wrapper, which leaves the column's type as it is.
12+
pub(crate) const LOW_CARDINALITY_WRAPPER: &str = "LowCardinality";
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use crate::analysis_route::constants::{LOW_CARDINALITY_WRAPPER, NULLABLE_WRAPPER};
2+
use crate::analysis_route::models::TypeMatch;
3+
4+
/// A declared and an inferred ClickHouse type compared without top-level wrappers, nullability apart.
5+
#[must_use]
6+
pub fn clickhouse_type_match(declared: &str, inferred: &str, inferred_nullable: bool) -> TypeMatch {
7+
let (declared_base, declared_nullable) = unwrapped(declared);
8+
let (inferred_base, wrapped_nullable) = unwrapped(inferred);
9+
TypeMatch {
10+
same_type: declared_base == inferred_base,
11+
nullable_into_non_null: !declared_nullable && (inferred_nullable || wrapped_nullable),
12+
}
13+
}
14+
15+
/// The type inside its top-level `Nullable` and `LowCardinality` wrappers, and whether it is nullable.
16+
fn unwrapped(type_sql: &str) -> (&str, bool) {
17+
let mut current: &str = type_sql.trim();
18+
let mut nullable: bool = false;
19+
loop {
20+
if let Some(inner) = wrapped(current, NULLABLE_WRAPPER) {
21+
nullable = true;
22+
current = inner;
23+
} else if let Some(inner) = wrapped(current, LOW_CARDINALITY_WRAPPER) {
24+
current = inner;
25+
} else {
26+
return (current, nullable);
27+
}
28+
}
29+
}
30+
31+
/// The argument of `wrapper(...)` when the whole of `type_sql` is that one call.
32+
fn wrapped<'a>(type_sql: &'a str, wrapper: &str) -> Option<&'a str> {
33+
let head: &str = type_sql.get(..wrapper.len())?;
34+
if !head.eq_ignore_ascii_case(wrapper) {
35+
return None;
36+
}
37+
let inner: &str = type_sql[wrapper.len()..]
38+
.trim_start()
39+
.strip_prefix('(')?
40+
.strip_suffix(')')?;
41+
let mut depth: usize = 0;
42+
for character in inner.chars() {
43+
match character {
44+
'(' => depth += 1,
45+
')' if depth == 0 => return None,
46+
')' => depth -= 1,
47+
_ => {}
48+
}
49+
}
50+
Some(inner.trim())
51+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod analysis_route;
2+
pub mod clickhouse_type_match;
23
pub mod native_gap;
34
pub mod skipped_analysis;
Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
1-
use std::collections::HashMap;
2-
31
use sqlbuild_sqltext::compiler::main::placeholder_defaults::placeholder_defaults;
42

53
use crate::analysis_route::models::{LocatedGap, ParserGap};
64
use crate::analysis_route::types::NativeDialect;
75
use crate::assembly::project::models::SyntaxCheck;
8-
use crate::semantic_validation::main::normalize::normalize_analysis_sql;
9-
use crate::semantic_validation::models::NormalizationInput;
6+
use crate::compiled_project::main::text_position::text_position;
7+
use crate::compiled_project::models::TextPosition;
8+
use crate::semantic_validation::main::normalize_native::normalize_native_sql;
109

11-
/// The first gap of the native parser in `check`'s SQL, normalised as analysis reads it, with the
12-
/// model's placeholder values filled; none when the parser reads it whole.
13-
///
14-
/// # Errors
15-
///
16-
/// The normalisation's message when the SQL cannot be normalised.
10+
/// The native parser's first gap in `check`'s analysis SQL, with the dialect's stub spelling.
1711
pub fn native_gap(
1812
dialect: &dyn NativeDialect,
1913
check: &SyntaxCheck,
2014
) -> Result<Option<LocatedGap>, String> {
21-
let normalised: String = normalize_analysis_sql(NormalizationInput {
22-
sql: check.sql.clone(),
23-
dialect: dialect.name().to_owned(),
24-
stubs: HashMap::new(),
25-
placeholders: HashMap::new(),
26-
})?;
15+
let normalised: String =
16+
normalize_native_sql(&check.sql, dialect.name(), dialect.stub_prefix())?;
2717
let analysed: String = placeholder_defaults(&normalised, &check.placeholders);
2818
Ok(dialect
2919
.read_whole(&analysed)
@@ -34,10 +24,10 @@ pub fn native_gap(
3424
/// The gap with the line and column of its offset; an offset past the end is the end.
3525
fn located(sql: &str, gap: ParserGap) -> LocatedGap {
3626
let before: &str = sql.get(..gap.offset).unwrap_or(sql);
37-
let line_start: usize = before.rfind('\n').map_or(0, |newline| newline + 1);
27+
let position: TextPosition = text_position(sql, before.chars().count());
3828
LocatedGap {
3929
construct: gap.construct,
40-
line: before.matches('\n').count() + 1,
41-
column: before[line_start..].chars().count() + 1,
30+
line: position.line,
31+
column: position.column,
4232
}
4333
}

‎crates/sqlbuild-analysis/src/analysis_route/models.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ pub struct LocatedGap {
2525
pub line: usize,
2626
pub column: usize,
2727
}
28+
29+
/// How an inferred column type meets its declared type: the type itself, then its nullability.
30+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31+
pub struct TypeMatch {
32+
pub same_type: bool,
33+
/// The inferred column may be null, but its declared type does not allow it.
34+
pub nullable_into_non_null: bool,
35+
}

‎crates/sqlbuild-analysis/src/analysis_route/tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[path = "tests/test_clickhouse_type_match.rs"]
2+
mod test_clickhouse_type_match;
13
#[path = "tests/test_native_gap.rs"]
24
mod test_native_gap;
35
#[path = "tests/test_types.rs"]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use crate::analysis_route::main::clickhouse_type_match::clickhouse_type_match;
2+
use crate::analysis_route::models::TypeMatch;
3+
use crate::analysis_route::tests::test_types::TypeMatchTestCase;
4+
5+
#[test]
6+
fn given_clickhouse_column_types_when_matching_then_wrappers_are_stripped_and_nullability_compared_apart()
7+
{
8+
let test_cases = [
9+
TypeMatchTestCase {
10+
description: "a nullable String flag matches DESCRIBE's Nullable(String)",
11+
declared: "Nullable(String)",
12+
inferred: "String",
13+
inferred_nullable: true,
14+
expected_match: TypeMatch {
15+
same_type: true,
16+
nullable_into_non_null: false,
17+
},
18+
},
19+
TypeMatchTestCase {
20+
description: "a declared LowCardinality(String) is a String",
21+
declared: "LowCardinality(String)",
22+
inferred: "String",
23+
inferred_nullable: false,
24+
expected_match: TypeMatch {
25+
same_type: true,
26+
nullable_into_non_null: false,
27+
},
28+
},
29+
TypeMatchTestCase {
30+
description: "LowCardinality around Nullable allows null",
31+
declared: "LowCardinality(Nullable(String))",
32+
inferred: "Nullable(String)",
33+
inferred_nullable: false,
34+
expected_match: TypeMatch {
35+
same_type: true,
36+
nullable_into_non_null: false,
37+
},
38+
},
39+
TypeMatchTestCase {
40+
description: "a nullable inference into a plain String declaration is a nullability conflict",
41+
declared: "String",
42+
inferred: "String",
43+
inferred_nullable: true,
44+
expected_match: TypeMatch {
45+
same_type: true,
46+
nullable_into_non_null: true,
47+
},
48+
},
49+
TypeMatchTestCase {
50+
description: "a non-null inference into a Nullable declaration is no conflict",
51+
declared: "Nullable(Int64)",
52+
inferred: "Int64",
53+
inferred_nullable: false,
54+
expected_match: TypeMatch {
55+
same_type: true,
56+
nullable_into_non_null: false,
57+
},
58+
},
59+
TypeMatchTestCase {
60+
description: "different base types differ whatever their wrappers",
61+
declared: "Nullable(Int32)",
62+
inferred: "Int64",
63+
inferred_nullable: true,
64+
expected_match: TypeMatch {
65+
same_type: false,
66+
nullable_into_non_null: false,
67+
},
68+
},
69+
TypeMatchTestCase {
70+
description: "a Nullable nested inside Array is part of the type, not stripped",
71+
declared: "Array(Nullable(String))",
72+
inferred: "Array(String)",
73+
inferred_nullable: false,
74+
expected_match: TypeMatch {
75+
same_type: false,
76+
nullable_into_non_null: false,
77+
},
78+
},
79+
];
80+
for test_case in test_cases {
81+
assert_eq!(
82+
clickhouse_type_match(
83+
test_case.declared,
84+
test_case.inferred,
85+
test_case.inferred_nullable
86+
),
87+
test_case.expected_match,
88+
"{}",
89+
test_case.description
90+
);
91+
}
92+
}

0 commit comments

Comments
 (0)