Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5fbcde1
WIP (unreviewed): sqlquack-semantics: SQLite checks and types abstain…
kvlonge Nov 7, 2026
864ffd6
WIP (unreviewed): SQLite tokenizer, rule for rule from SQLite 3.53.4'…
kvlonge Nov 7, 2026
ca1af18
Merge remote-tracking branch 'origin/main' into wip/sqlite-semantics
kvlonge Nov 7, 2026
b92b8d3
WIP: SQLite parser agrees with the 3.53.4 engine on the catalogue's q…
kvlonge Nov 7, 2026
0665306
WIP (unreviewed): sqlquack-semantics: test SQLite window-only calls r…
kvlonge Nov 7, 2026
a73c741
WIP: SQLite parser holds trees to the shared AST depth guard and coun…
kvlonge Nov 7, 2026
f840f0e
Merge remote-tracking branch 'origin/main' into wip/sqlite-parser
kvlonge Nov 7, 2026
d210e49
WIP: SQLite parser sets the shared tree's new set-operation, join and…
kvlonge Nov 7, 2026
bbcc0c1
Merge remote-tracking branch 'origin/main' into wip/sqlite-semantics
kvlonge Nov 7, 2026
cd6f56c
Merge remote-tracking branch 'origin/main' into wip/sqlite-parser
kvlonge Nov 7, 2026
79a5348
WIP: SQLite parser README records its nesting limits against sqlite3 …
kvlonge Nov 7, 2026
53699a8
WIP: SQLite parser README counts compound SELECT terms as SQLite does
kvlonge Nov 7, 2026
57138bc
Merge remote-tracking branch 'origin/main' into wip/sqlite-depth-limits
kvlonge Nov 8, 2026
22e7005
WIP: SQLite parser allows trees up to 8,000 levels now that the share…
kvlonge Nov 8, 2026
303bf24
Merge commit '22e7005d8' into wip/sqlite-semantics
kvlonge Nov 8, 2026
ffa9993
WIP (unreviewed): sqlquack-semantics: walk SQLite trees nested to the…
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
11 changes: 11 additions & 0 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 @@ -15,6 +15,7 @@ members = [
"crates/sqlquack-syntax-coverage",
"crates/sqlquack-syntax-postgres",
"crates/sqlquack-syntax-bigquery",
"crates/sqlquack-syntax-sqlite",
"crates/sqlquack-python",
]
exclude = ["fuzz"]
Expand Down Expand Up @@ -66,6 +67,7 @@ sqlquack-syntax = { path = "crates/sqlquack-syntax" }
sqlquack-syntax-derive = { path = "crates/sqlquack-syntax-derive" }
sqlquack-syntax-postgres = { path = "crates/sqlquack-syntax-postgres" }
sqlquack-syntax-bigquery = { path = "crates/sqlquack-syntax-bigquery" }
sqlquack-syntax-sqlite = { path = "crates/sqlquack-syntax-sqlite" }
stacker = "0.1"
syn = { version = "2", features = ["full"] }
tempfile = "3"
Expand Down
2 changes: 2 additions & 0 deletions crates/sqlquack-semantics/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ serde_json.workspace = true
sqlquack-syntax.workspace = true
sqlquack-core.workspace = true
sqlquack-snowflake.workspace = true
stacker.workspace = true

[dev-dependencies]
sqlquack-syntax-postgres.workspace = true
sqlquack-syntax-sqlite.workspace = true

[lints]
workspace = true
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use sqlquack_syntax::Expression;
use sqlquack_syntax::expressions::names::models::Literal;

use crate::lineage::_helpers::checks::families::Family;
use crate::lineage::constants::{DUCKDB_PROFILE, POSTGRES_PROFILE};
use crate::lineage::constants::{DUCKDB_PROFILE, POSTGRES_PROFILE, SQLITE_PROFILE};
use crate::lineage::models::LineageContext;

/// The length of an ISO date, `YYYY-MM-DD`, and where its dashes and parts are.
Expand All @@ -30,13 +30,16 @@ pub(crate) enum Engine {
Snowflake,
/// PostgreSQL: no implicit predicate, comparison or string set-operation coercions.
Postgres,
/// SQLite, whose dynamic typing is not recorded: no type findings, only scope and name rules.
Sqlite,
}

impl Engine {
pub(crate) fn of(context: LineageContext<'_>) -> Self {
match context.profile.name() {
DUCKDB_PROFILE => Self::DuckDB,
POSTGRES_PROFILE => Self::Postgres,
SQLITE_PROFILE => Self::Sqlite,
_ => Self::Snowflake,
}
}
Expand All @@ -46,7 +49,7 @@ impl Engine {
match self {
Self::DuckDB => &[Family::Integer, Family::Numeric],
Self::Snowflake => &[Family::Integer, Family::Numeric, Family::String],
Self::Postgres => &[],
Self::Postgres | Self::Sqlite => &[],
}
}

Expand All @@ -67,7 +70,7 @@ impl Engine {
(Family::Integer, Family::Boolean),
(Family::Numeric, Family::Boolean),
],
Self::Postgres => &[],
Self::Postgres | Self::Sqlite => &[],
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ fn finding(code: &'static str, message: String, node: &Expression) -> NameFindin

/// The type findings of `node`, as polyglot's type checks report them, in its order.
pub(crate) fn node_findings(node: &Expression, level: NodeLevel<'_, '_>) -> Vec<NameFinding> {
let sqlite: bool = level.engine() == Engine::Sqlite;
let mut findings: Vec<NameFinding> = match node {
Expression::Select(select) => clause_predicates(select, node, level),
Expression::Select(select) if !sqlite => clause_predicates(select, node, level),
_ => Vec::new(),
};
if let Expression::Function(function) = node
Expand All @@ -67,6 +68,10 @@ pub(crate) fn node_findings(node: &Expression, level: NodeLevel<'_, '_>) -> Vec<
}
_ => Vec::new(),
};
if sqlite {
findings.extend(located(own, node));
return findings;
}
let checked = check_call(node, level.engine(), &family);
let signed: bool = checked.as_ref().is_some_and(|checked| checked.signed);
own.extend(checked.map(|checked| checked.findings).unwrap_or_default());
Expand Down Expand Up @@ -166,7 +171,7 @@ fn clause_predicates(
fn predicate_code(engine: Engine) -> &'static str {
match engine {
Engine::DuckDB => "W215",
Engine::Snowflake | Engine::Postgres => "E211",
Engine::Snowflake | Engine::Postgres | Engine::Sqlite => "E211",
}
}

Expand Down Expand Up @@ -233,7 +238,7 @@ fn expression_checks(node: &Expression, level: NodeLevel<'_, '_>) -> Vec<NameFin
let code: Option<&'static str> = match engine {
Engine::Snowflake => Some("E218"),
Engine::DuckDB => Some("W213"),
Engine::Postgres => None,
Engine::Postgres | Engine::Sqlite => None,
};
findings.extend(
code.map(|code| finding(code, "Invalid interval literal".to_owned(), node)),
Expand Down Expand Up @@ -473,7 +478,7 @@ fn impossible_cast(
impossible.then(|| {
finding(
match engine {
Engine::Snowflake | Engine::Postgres => "E218",
Engine::Snowflake | Engine::Postgres | Engine::Sqlite => "E218",
Engine::DuckDB => "W213",
},
format!("Cannot cast {} to {}", source.name(), target.name()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ pub(crate) fn recursive_findings(
let literal: Option<&Expression> = projection_literal(&union.right, index);
let invalid: bool = match engine {
Engine::Snowflake => !setop_compatible(target, source),
Engine::Sqlite => false,
Engine::DuckDB | Engine::Postgres => {
literal.is_some_and(|value| invalid_literal_for(value, target))
|| (source == Family::String
Expand All @@ -197,7 +198,7 @@ pub(crate) fn recursive_findings(
findings.push(finding(
match engine {
Engine::Snowflake => "E215",
Engine::DuckDB | Engine::Postgres => "W214",
Engine::DuckDB | Engine::Postgres | Engine::Sqlite => "W214",
},
"Recursive branch cannot convert to the anchor column type".to_owned(),
node,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::lineage::_helpers::checks::issues::{in_scope, issue, rule_location};
use crate::lineage::_helpers::tracing::expressions::is_aggregate;
use crate::lineage::_helpers::tracing::resolution::key;
use crate::lineage::_helpers::typing::postgres_typing::postgres_window_only;
use crate::lineage::constants::{POSTGRES_PROFILE, SCOPE_PHASE, SNOWFLAKE_PROFILE};
use crate::lineage::constants::{POSTGRES_PROFILE, SCOPE_PHASE, SNOWFLAKE_PROFILE, SQLITE_PROFILE};
use crate::lineage::models::{LineageContext, LineageScope, NameFinding, ScopeSource};

const COLUMNS_FUNCTION: &str = "columns";
Expand Down Expand Up @@ -253,7 +253,7 @@ fn structure_rules(
for cte in &with.ctes {
let profile: &str = level.context.profile.name();
let mismatched = |width: usize| match profile {
SNOWFLAKE_PROFILE => width != cte.columns.len(),
SNOWFLAKE_PROFILE | SQLITE_PROFILE => width != cte.columns.len(),
POSTGRES_PROFILE => width < cte.columns.len(),
_ => false,
};
Expand All @@ -266,7 +266,7 @@ fn structure_rules(
));
}
if !names.insert(level.key(&cte.alias))
&& matches!(engine, Engine::DuckDB | Engine::Postgres)
&& matches!(engine, Engine::DuckDB | Engine::Postgres | Engine::Sqlite)
{
findings.push(rule(
"E233",
Expand All @@ -278,7 +278,10 @@ fn structure_rules(
}
}
let mut names: HashSet<String> = HashSet::new();
for item in from_items(select) {
for item in from_items(select)
.into_iter()
.filter(|_| engine != Engine::Sqlite)
{
if let Some(name) = item_name(item)
&& !names.insert(level.key(name))
{
Expand Down Expand Up @@ -312,7 +315,7 @@ fn structure_rules(
}
}
}
if matches!(engine, Engine::Postgres) {
if matches!(engine, Engine::Postgres | Engine::Sqlite) {
findings.extend(unwindowed_calls(node));
}
if select.qualify.is_some() && !has_window {
Expand All @@ -330,7 +333,11 @@ fn structure_rules(
.map(|limit| &limit.this)
.chain(select.offset.iter().map(|offset| &offset.this));
for value in bounds {
if invalid_bound(value) {
let invalid: bool = match engine {
Engine::Sqlite => matches!(value, Expression::Column(_)),
_ => invalid_bound(value),
};
if invalid {
findings.push(rule(
"E234",
"LIMIT/OFFSET must be a non-negative constant numeric expression".to_owned(),
Expand Down Expand Up @@ -739,12 +746,15 @@ fn offers(source: &ScopeSource, name: &str, level: RuleLevel<'_, '_>) -> bool {
.any(|offered| level.context.profile.same_identifier(&offered.key, name))
}

/// Columns an aggregate query reads that are neither grouped nor aggregated.
/// Ungrouped, unaggregated columns an aggregate query reads; none in SQLite, which reads any row.
fn grouping(
(node, select): (&Expression, &Select),
aliases: &HashMap<String, &Expression>,
level: RuleLevel<'_, '_>,
) -> Vec<NameFinding> {
if Engine::of(level.context) == Engine::Sqlite {
return Vec::new();
}
let aggregate_query: bool = select.group_by.is_some()
|| select.expressions.iter().any(ordinary_aggregate)
|| select
Expand All @@ -765,13 +775,24 @@ fn grouping(
}
let groups: Vec<&Expression> = group_keys(select, aliases, level);
let unmarked_groups: Vec<&Expression> = groups.iter().map(|group| unmarked(group)).collect();
let grouped_columns: HashSet<(Option<String>, String)> = groups
let grouped_columns: Vec<(Option<String>, String)> = groups
.iter()
.filter_map(|group| match group {
Expression::Column(column) => Some(column_key(column, level)),
_ => None,
})
.collect();
let same = |left: &str, right: &str| level.context.profile.same_identifier(left, right);
let grouped = |(table, name): &(Option<String>, String)| {
grouped_columns.iter().any(|(grouped_table, grouped_name)| {
same(grouped_name, name)
&& match (grouped_table, table) {
(Some(left), Some(right)) => same(left, right),
(None, None) => true,
_ => false,
}
})
};
let aliases_outside_order: bool = Engine::of(level.context) != Engine::Postgres;
let mut read: Vec<(&Expression, bool)> = select
.expressions
Expand Down Expand Up @@ -817,7 +838,7 @@ fn grouping(
continue;
}
if let Expression::Column(column) = current {
if grouped_columns.contains(&column_key(column, level)) {
if grouped(&column_key(column, level)) {
continue;
}
if column.table.is_none() && named {
Expand Down
48 changes: 37 additions & 11 deletions crates/sqlquack-semantics/src/lineage/_helpers/checks/sets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ pub(crate) fn set_findings(
};
let findings: Vec<NameFinding> = match Engine::of(context) {
Engine::Snowflake => snowflake_findings(query, branches, context),
Engine::Sqlite => width_findings((operator, query), (left, right), branches, context)
.into_iter()
.collect(),
engine @ (Engine::DuckDB | Engine::Postgres) => {
binder_findings((engine, query, operator), (left, right), branches, context)
}
Expand All @@ -177,6 +180,38 @@ pub(crate) fn set_findings(
.collect()
}

/// The finding of branches whose column counts differ, which every engine refuses.
fn width_finding(
operator: &str,
(left, right): (&[Family], &[Family]),
query: &Expression,
) -> Option<NameFinding> {
(left.len() != right.len()).then(|| {
issue(
("E216", TYPE_PHASE),
format!(
"{operator} operands return different column counts: left {}, right {}",
left.len(),
right.len()
),
None,
query,
)
})
}

/// SQLite's findings: column counts only, as its coercions are not recorded.
fn width_findings(
(operator, query): (&str, &Expression),
(left, right): (&Expression, &Expression),
branches: Branches<'_, '_>,
context: LineageContext<'_>,
) -> Option<NameFinding> {
let left_families: Vec<Family> = families(left, branches, context)?;
let right_families: Vec<Family> = families(right, branches, context)?;
width_finding(operator, (&left_families, &right_families), query)
}

/// DuckDB's and PostgreSQL's findings: column counts, then each column's families.
fn binder_findings(
(engine, query, operator): (Engine, &Expression, &str),
Expand All @@ -190,17 +225,8 @@ fn binder_findings(
) else {
return Vec::new();
};
if left_families.len() != right_families.len() {
return vec![issue(
("E216", TYPE_PHASE),
format!(
"{operator} operands return different column counts: left {}, right {}",
left_families.len(),
right_families.len()
),
None,
query,
)];
if let Some(finding) = width_finding(operator, (&left_families, &right_families), query) {
return vec![finding];
}
let literal = |branch: &Expression, index: usize, target: Family| {
projection_literal(branch, index).is_some_and(|value| literal_coerces(value, target))
Expand Down
10 changes: 10 additions & 0 deletions crates/sqlquack-semantics/src/lineage/_helpers/findings/levels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ pub(crate) fn query_findings(
query: &Expression,
(ctes, outer, aliases): (&[NamedQuery], Option<&LineageScope<'_>>, &[String]),
context: LineageContext<'_>,
) -> Vec<NameFinding> {
crate::lineage::_helpers::tracing::stack::deeper(|| {
query_findings_level(query, (ctes, outer, aliases), context)
})
}

fn query_findings_level(
query: &Expression,
(ctes, outer, aliases): (&[NamedQuery], Option<&LineageScope<'_>>, &[String]),
context: LineageContext<'_>,
) -> Vec<NameFinding> {
let branches = |with: Option<&With>, left: &Expression, right: &Expression| {
let (ctes, mut found) = visited_ctes(with, (ctes, outer, aliases), context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ use crate::lineage::_helpers::typing::postgres_typing::postgres_cast_spelling;
use crate::lineage::_helpers::typing::rules::{
ValueType, computed_type, literal_type, mapped_timestamp, rendered_type, required_non_null,
};
use crate::lineage::_helpers::typing::sqlite_typing::sqlite_cast_spelling;
use crate::lineage::constants::{
AGGREGATE_FUNCTIONS, BOOLEAN_TYPE, DATE_PART_FUNCTIONS, DUCKDB_PROFILE, POSITIONAL_COLUMN_BASE,
POSTGRES_PROFILE, SNOWFLAKE_JSON_HOLDERS, SNOWFLAKE_PROFILE, SNOWFLAKE_VARIANT_TYPE,
SQLITE_PROFILE,
};
use crate::lineage::models::{
Lineage, LineageContext, LineageScope, Nullability, ResolvedQuery, RowReads, TransformKind,
Expand All @@ -29,6 +31,16 @@ pub(crate) fn expression_lineage(
expression: &Expression,
scope: &LineageScope<'_>,
context: LineageContext<'_>,
) -> Lineage {
crate::lineage::_helpers::tracing::stack::deeper(|| {
expression_lineage_level(expression, scope, context)
})
}

fn expression_lineage_level(
expression: &Expression,
scope: &LineageScope<'_>,
context: LineageContext<'_>,
) -> Lineage {
match expression {
Expression::Column(column) => {
Expand Down Expand Up @@ -81,6 +93,7 @@ pub(crate) fn expression_lineage(
let target: Option<String> = match context.profile.name() {
DUCKDB_PROFILE => cast_spelling(&cast.to),
POSTGRES_PROFILE => postgres_cast_spelling(&cast.to),
SQLITE_PROFILE => sqlite_cast_spelling(&cast.to),
_ => rendered_type(&cast.to),
};
let target: Option<Rc<str>> =
Expand Down
Loading
Loading