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
12 changes: 12 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! 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";
/// 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";
12 changes: 12 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/main/analysis_route.rs
Original file line number Diff line number Diff line change
@@ -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)
})
}
Original file line number Diff line number Diff line change
@@ -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())
}
4 changes: 4 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/main/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod analysis_route;
pub mod clickhouse_type_match;
pub mod native_gap;
pub mod skipped_analysis;
33 changes: 33 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/main/native_gap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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::compiled_project::main::text_position::text_position;
use crate::compiled_project::models::TextPosition;
use crate::semantic_validation::main::normalize_native::normalize_native_sql;

/// 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<Option<LocatedGap>, String> {
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)
.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 position: TextPosition = text_position(sql, before.chars().count());
LocatedGap {
construct: gap.construct,
line: position.line,
column: position.column,
}
}
Original file line number Diff line number Diff line change
@@ -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
),
)
}
9 changes: 9 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! 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,
}

/// 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,
}
6 changes: 6 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#[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"]
mod test_types;
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading
Loading