Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2cd06dc
feat(analysis): give native dialects a parse and type contract
kvlonge Nov 8, 2026
b320f8e
feat(analysis): analyse native dialect queries with sqlquack-semantics
kvlonge Nov 8, 2026
eed3343
fix(types): take a fallback type's family from its top-level type
kvlonge Nov 8, 2026
73af347
fix(analysis): compare ClickHouse nullability without a type, and kee…
kvlonge Nov 8, 2026
6e514a2
fix(analysis): skip the Polyglot pivot proof for natively analysed di…
kvlonge Nov 8, 2026
b2d0fee
Revert "fix(analysis): skip the Polyglot pivot proof for natively ana…
kvlonge Nov 8, 2026
583e3ea
refactor(analysis): shape the native analysis route as fensu's Rust r…
kvlonge Nov 8, 2026
359a372
Merge the sqlquack 19c51fa2 bump into the native analysis route
kvlonge Nov 8, 2026
80039dc
chore(deps): pin sqlquack-semantics to 19c51fa2 with the other sqlqua…
kvlonge Nov 8, 2026
a003a5c
fix(analysis): let a native route's parser decide syntax, after the r…
kvlonge Nov 8, 2026
382d3cf
feat(analysis): analyse models on a native route with the dialect's o…
kvlonge Nov 8, 2026
1837342
fix(analysis): find a native route's stubs with the dialect's own quo…
kvlonge Nov 8, 2026
aac5217
fix(analysis): keep the analysis cache on a native route
kvlonge Nov 8, 2026
06bad7a
fix(lineage): build fast lineage natively for models without facts
kvlonge Nov 8, 2026
f07c13a
test(types): re-record the generated type golden for nested timestamps
kvlonge Nov 8, 2026
a6f1efd
test(lineage): trace fast lineage after a warm restore
kvlonge Nov 8, 2026
5bb708f
fix(analysis): gate a native route on a model's named hooks too
kvlonge Nov 8, 2026
7cf9e47
fix(analysis): know the adapter's declared functions on a native route
kvlonge Nov 8, 2026
9c886dd
refactor(analysis): shape the native route's model and lineage entrie…
kvlonge Nov 8, 2026
d9a9e16
fix(analysis): revalidate type recovery with the native analysis on n…
kvlonge Nov 8, 2026
db4420d
fix(analysis): keep marker lineage and SQLBuild's text checks on nati…
kvlonge Nov 8, 2026
5813dd1
fix(sqltext): read T-SQL bracket names and ClickHouse quoting in dial…
kvlonge Nov 8, 2026
1eaa0f1
fix(analysis): read repeated markers as one relation and keep unreada…
kvlonge Nov 8, 2026
195fa99
fix(analysis): name the hook and the model file line in P026
kvlonge Nov 8, 2026
4409f42
feat(analysis): let a native dialect respell session column types
kvlonge Nov 8, 2026
b7b6cd6
fix(analysis): give native routes the session's functions, quoting an…
kvlonge Nov 8, 2026
99785e6
fix(analysis): quote an unknown quoted column as written
kvlonge Nov 8, 2026
9e9d911
test(analysis): expect the native refusal to complete without a sessi…
kvlonge Nov 8, 2026
fc816af
fix(analysis): type a native route's reads of declared but open relat…
kvlonge Nov 8, 2026
098499d
fix(deps): resolve tempfile against the workspace's windows-sys again
kvlonge Nov 8, 2026
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
40 changes: 36 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ sqlbuild-sqltext = { path = "crates/sqlbuild-sqltext" }
sqlbuild-state = { path = "crates/sqlbuild-state" }
# SQL lint Rules, quality Rules and the formatter.
sqlclean = { git = "https://github.com/sqlstack-io/sqlclean.git", rev = "cf79656d247277a30bc81ef39b6dee420e721221" }
# Scopes, name resolution, types and lineage for the native analysis route.
sqlquack-semantics = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "19c51fa29ab01dbef9dddd18e8bfc406ed8e65f5" }
# The lossless SQL tokenizer behind query fingerprints.
sqlquack-syntax = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "19c51fa29ab01dbef9dddd18e8bfc406ed8e65f5" }
sqlquack-syntax-postgres = { git = "https://github.com/sqlstack-io/sqlquack.git", rev = "19c51fa29ab01dbef9dddd18e8bfc406ed8e65f5" }
Expand Down
1 change: 1 addition & 0 deletions crates/sqlbuild-analysis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ sqlbuild-render.workspace = true
sqlbuild-scopes.workspace = true
sqlbuild-sqltext.workspace = true
sqlclean.workspace = true
sqlquack-semantics.workspace = true
sqlquack-syntax.workspace = true
sqlquack-syntax-postgres.workspace = true

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use crate::analysis_route::constants::{LOW_CARDINALITY_WRAPPER, NULLABLE_WRAPPER};

/// `type_sql` without whitespace outside its quoted strings.
pub(crate) fn unspaced(type_sql: &str) -> String {
let mut quoted: bool = false;
type_sql
.chars()
.filter(|character| {
if *character == '\'' {
quoted = !quoted;
}
quoted || !character.is_whitespace()
})
.collect()
}

/// The type inside its top-level `Nullable` and `LowCardinality` wrappers, and whether it is nullable.
pub(crate) 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())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Where a native parser's gap is, and why the parser cannot read the SQL there.

use crate::analysis_route::models::{GapPosition, GapSite, LocatedGap, ParserGap};
use crate::compiled_project::main::text_position::text_position;
use crate::compiled_project::models::TextPosition;

/// The gap at its line and column in `sql`, and its offset in `source` when the text holds.
pub(crate) fn located(sql: &str, gap: ParserGap, source: Option<&str>) -> LocatedGap {
let before: &str = sql.get(..gap.offset).unwrap_or(sql);
let offset: usize = before.chars().count();
let position: TextPosition = text_position(sql, offset);
LocatedGap {
construct: gap.construct,
message: gap.message,
line: position.line,
column: position.column,
syntax: gap.syntax,
source_offset: source.and_then(|source| aligned(sql, offset, source)),
}
}

/// The character of `source` that `derived`'s `offset` came from, when the text before or after holds.
pub(crate) fn aligned(derived: &str, offset: usize, source: &str) -> Option<usize> {
let derived: Vec<char> = derived.chars().collect();
let source: Vec<char> = source.chars().collect();
if offset > derived.len() {
return None;
}
if source.starts_with(&derived[..offset]) {
return Some(offset);
}
source
.ends_with(&derived[offset..])
.then(|| source.len() - (derived.len() - offset))
}

/// Why the dialect's native parser cannot read SQL, as P026 words it.
pub(crate) fn unreadable_reason(dialect: &str, gap: &LocatedGap) -> String {
unreadable_at(dialect, gap, (gap.line, gap.column, ""))
}

/// P026's text for a gap at its site: the hook and file, then the parser's reason there.
pub(crate) fn sited_reason(dialect: &str, gap: &LocatedGap, site: &GapSite<'_>) -> String {
let label: String = site
.label
.map_or_else(String::new, |label| format!(" {label}"));
let at: (usize, usize, &str) = match site.position {
GapPosition::File(position) => (position.line, position.column, ""),
GapPosition::Statement(position) => (position.line, position.column, " of its SQL"),
GapPosition::Analysed => (gap.line, gap.column, " of the analysed SQL"),
};
format!(
"{label} ({}): {}",
site.path,
unreadable_at(dialect, gap, at)
)
}

fn unreadable_at(dialect: &str, gap: &LocatedGap, at: (usize, usize, &str)) -> String {
let (line, column, within) = at;
let detail: String = if gap.message.is_empty() || gap.message == gap.construct {
String::new()
} else {
format!(": {}", gap.message)
};
format!(
"the {dialect} parser cannot read {} at line {line}, column {column}{within}{detail}",
gap.construct
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! One analysis name per distinct relation marker call, so repeated calls read as one relation.

use std::sync::LazyLock;

use regex::{Captures, Regex};

use crate::relation_markers::constants::MARKER_NAME_PREFIX;
use crate::relation_markers::types::MarkerCall;

static MARKER_NAME: LazyLock<Result<Regex, String>> = LazyLock::new(|| {
Regex::new(&format!(r"\b{MARKER_NAME_PREFIX}(\d+)\b")).map_err(|error| error.to_string())
});

/// `named` with each marker name renamed to its call's first index, padded to keep positions.
pub(crate) fn first_call_names(named: &str, calls: &[MarkerCall]) -> Result<String, String> {
let pattern: &Regex = MARKER_NAME.as_ref().map_err(Clone::clone)?;
Ok(pattern
.replace_all(named, |captures: &Captures<'_>| {
let written: &str = captures.get(0).map_or("", |found| found.as_str());
let first: Option<usize> = match captures.get(1).map(|index| index.as_str().parse()) {
Some(Ok(index)) => first_index(calls, index),
Some(Err(_)) | None => None,
};
match first {
Some(first) => {
let name: String = format!("{MARKER_NAME_PREFIX}{first}");
format!("{name:<width$}", width = written.len())
}
None => written.to_owned(),
}
})
.into_owned())
}

/// The calls whose index is their call's first, in order, with that index.
pub(crate) fn distinct_calls(calls: &[MarkerCall]) -> Vec<(usize, &MarkerCall)> {
let mut distinct: Vec<(usize, &MarkerCall)> = Vec::with_capacity(calls.len());
for (index, call) in calls.iter().enumerate() {
if first_index(calls, index) == Some(index) {
distinct.push((index, call));
}
}
distinct
}

/// The first index of the call at `index`, which is `index` unless an earlier call is the same.
fn first_index(calls: &[MarkerCall], index: usize) -> Option<usize> {
let call: &MarkerCall = calls.get(index)?;
calls.iter().position(|earlier| earlier == call)
}
6 changes: 6 additions & 0 deletions crates/sqlbuild-analysis/src/analysis_route/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub(crate) mod clickhouse_types;
pub(crate) mod located_gap;
pub(crate) mod marker_names;
pub(crate) mod native_analysis;
pub(crate) mod revalidation;
pub(crate) mod whole_gap;
Loading
Loading