Skip to content

Commit a759b7c

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

12 files changed

Lines changed: 258 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//! The native dialects and the diagnostic a model the native parser cannot read takes.
2+
3+
use crate::analysis_route::types::NativeDialect;
4+
5+
/// Each dialect, by its adapter's analysis dialect name, whose SQL a native module analyses.
6+
pub(crate) const NATIVE_DIALECTS: &[(&str, &dyn NativeDialect)] = &[];
7+
/// The warning, or under `require_sql_analysis` the error, for a model analysis skips.
8+
pub(crate) const SKIPPED_ANALYSIS_CODE: &str = "P026";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use crate::analysis_route::constants::NATIVE_DIALECTS;
2+
use crate::analysis_route::models::AnalysisRoute;
3+
4+
/// The route of `dialect`, an adapter's analysis dialect name; unlisted dialects stay on Polyglot.
5+
#[must_use]
6+
pub fn analysis_route(dialect: Option<&str>) -> AnalysisRoute {
7+
dialect
8+
.and_then(|name| NATIVE_DIALECTS.iter().find(|(native, _)| *native == name))
9+
.map_or(AnalysisRoute::Legacy, |(_, module)| {
10+
AnalysisRoute::Native(*module)
11+
})
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod analysis_route;
2+
pub mod native_gap;
3+
pub mod skipped_analysis;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use std::collections::HashMap;
2+
3+
use sqlbuild_sqltext::compiler::main::placeholder_defaults::placeholder_defaults;
4+
5+
use crate::analysis_route::models::{LocatedGap, ParserGap};
6+
use crate::analysis_route::types::NativeDialect;
7+
use crate::assembly::project::models::SyntaxCheck;
8+
use crate::semantic_validation::main::normalize::normalize_analysis_sql;
9+
use crate::semantic_validation::models::NormalizationInput;
10+
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.
17+
pub fn native_gap(
18+
dialect: &dyn NativeDialect,
19+
check: &SyntaxCheck,
20+
) -> 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+
})?;
27+
let analysed: String = placeholder_defaults(&normalised, &check.placeholders);
28+
Ok(dialect
29+
.read_whole(&analysed)
30+
.err()
31+
.map(|gap| located(&analysed, gap)))
32+
}
33+
34+
/// The gap with the line and column of its offset; an offset past the end is the end.
35+
fn located(sql: &str, gap: ParserGap) -> LocatedGap {
36+
let before: &str = sql.get(..gap.offset).unwrap_or(sql);
37+
let line_start: usize = before.rfind('\n').map_or(0, |newline| newline + 1);
38+
LocatedGap {
39+
construct: gap.construct,
40+
line: before.matches('\n').count() + 1,
41+
column: before[line_start..].chars().count() + 1,
42+
}
43+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use crate::analysis_route::constants::SKIPPED_ANALYSIS_CODE;
2+
use crate::analysis_route::models::LocatedGap;
3+
4+
/// P026's code and message for a model whose SQL the dialect's native parser cannot read.
5+
#[must_use]
6+
pub fn skipped_analysis(model: &str, dialect: &str, gap: &LocatedGap) -> (&'static str, String) {
7+
(
8+
SKIPPED_ANALYSIS_CODE,
9+
format!(
10+
"SQL analysis skipped for model '{model}': the {dialect} parser cannot read {} at \
11+
line {}, column {}",
12+
gap.construct, gap.line, gap.column
13+
),
14+
)
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//! Which engine analyses a dialect's SQL: the Polyglot path, or a native module over SQLQuack.
2+
3+
pub(crate) mod constants;
4+
pub mod main;
5+
pub mod models;
6+
pub mod types;
7+
8+
#[cfg(test)]
9+
mod tests;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! The route a dialect's analysis takes, and what stops a native parser reading SQL whole.
2+
3+
use crate::analysis_route::types::NativeDialect;
4+
5+
/// The engine that analyses a dialect's SQL.
6+
#[derive(Clone, Copy)]
7+
pub enum AnalysisRoute {
8+
/// Polyglot, as before the dialect's native module.
9+
Legacy,
10+
/// The dialect's native module; a model it cannot read whole is not analysed.
11+
Native(&'static dyn NativeDialect),
12+
}
13+
14+
/// The first construct a native parser cannot read, and its byte offset in the SQL it read.
15+
#[derive(Clone, Debug, PartialEq, Eq)]
16+
pub struct ParserGap {
17+
pub construct: String,
18+
pub offset: usize,
19+
}
20+
21+
/// A gap with the line and column, from 1, of its offset in the analysed SQL.
22+
#[derive(Clone, Debug, PartialEq, Eq)]
23+
pub struct LocatedGap {
24+
pub construct: String,
25+
pub line: usize,
26+
pub column: usize,
27+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#[path = "tests/test_native_gap.rs"]
2+
mod test_native_gap;
3+
#[path = "tests/test_types.rs"]
4+
mod test_types;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
use crate::analysis_route::main::analysis_route::analysis_route;
2+
use crate::analysis_route::main::native_gap::native_gap;
3+
use crate::analysis_route::main::skipped_analysis::skipped_analysis;
4+
use crate::analysis_route::models::{AnalysisRoute, LocatedGap, ParserGap};
5+
use crate::analysis_route::tests::test_types::NativeGapTestCase;
6+
use crate::analysis_route::types::NativeDialect;
7+
use crate::assembly::project::models::SyntaxCheck;
8+
9+
/// A test dialect whose parser refuses the word PIVOT and an unfilled `@@@` placeholder.
10+
struct PivotlessDialect;
11+
12+
impl NativeDialect for PivotlessDialect {
13+
fn name(&self) -> &'static str {
14+
"postgres"
15+
}
16+
17+
fn read_whole(&self, sql: &str) -> Result<(), ParserGap> {
18+
let refused = ["PIVOT", "@@@"]
19+
.iter()
20+
.filter_map(|word| sql.find(word).map(|offset| (offset, *word)))
21+
.min();
22+
match refused {
23+
Some((offset, word)) => Err(ParserGap {
24+
construct: word.to_owned(),
25+
offset,
26+
}),
27+
None => Ok(()),
28+
}
29+
}
30+
}
31+
32+
#[test]
33+
fn given_any_dialect_when_routing_analysis_then_it_stays_on_the_legacy_path() {
34+
for dialect in [
35+
Some("postgres"),
36+
Some("snowflake"),
37+
Some("duckdb"),
38+
Some("sqlite"),
39+
Some("bigquery"),
40+
Some("databricks"),
41+
Some("clickhouse"),
42+
Some("tsql"),
43+
None,
44+
] {
45+
assert!(
46+
matches!(analysis_route(dialect), AnalysisRoute::Legacy),
47+
"{dialect:?}"
48+
);
49+
}
50+
}
51+
52+
#[test]
53+
fn given_model_sql_when_probing_the_native_parser_then_its_gap_is_located_after_placeholders() {
54+
let test_cases = [
55+
NativeGapTestCase {
56+
description: "SQL the parser reads whole has no gap",
57+
sql: "SELECT 1 AS a",
58+
placeholders: &[],
59+
expected_gap: None,
60+
},
61+
NativeGapTestCase {
62+
description: "a placeholder is filled before the parser reads the SQL",
63+
sql: "SELECT * FROM t WHERE region = @@@region",
64+
placeholders: &[("region", "'eu'")],
65+
expected_gap: None,
66+
},
67+
NativeGapTestCase {
68+
description: "the gap's line and column count from 1, in characters",
69+
sql: "SELECT 'é' AS a\nFROM t\n PIVOT (sum(x) FOR y IN (1))",
70+
placeholders: &[],
71+
expected_gap: Some(LocatedGap {
72+
construct: "PIVOT".to_owned(),
73+
line: 3,
74+
column: 3,
75+
}),
76+
},
77+
];
78+
for test_case in test_cases {
79+
let check = SyntaxCheck {
80+
sql: test_case.sql.to_owned(),
81+
placeholders: test_case
82+
.placeholders
83+
.iter()
84+
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
85+
.collect(),
86+
};
87+
assert_eq!(
88+
native_gap(&PivotlessDialect, &check),
89+
Ok(test_case.expected_gap),
90+
"{}",
91+
test_case.description
92+
);
93+
}
94+
}
95+
96+
#[test]
97+
fn given_a_located_gap_when_skipping_analysis_then_p026_names_the_model_and_the_gap() {
98+
let gap = LocatedGap {
99+
construct: "PIVOT".to_owned(),
100+
line: 3,
101+
column: 3,
102+
};
103+
assert_eq!(
104+
skipped_analysis("orders_pivot", "postgres", &gap),
105+
(
106+
"P026",
107+
"SQL analysis skipped for model 'orders_pivot': the postgres parser cannot read PIVOT \
108+
at line 3, column 3"
109+
.to_owned()
110+
)
111+
);
112+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use crate::analysis_route::models::LocatedGap;
2+
3+
pub(super) struct NativeGapTestCase {
4+
pub(super) description: &'static str,
5+
pub(super) sql: &'static str,
6+
pub(super) placeholders: &'static [(&'static str, &'static str)],
7+
pub(super) expected_gap: Option<LocatedGap>,
8+
}

0 commit comments

Comments
 (0)