Skip to content

Commit 5bda716

Browse files
feat(sql): resolve table aliases across L2/L3/L4 safety checks (#41)
* P3: prove TypeCompat (level 3) as a real operand-type-compatibility guarantee Continues the flagship semantic-proof coverage (InjectionFree level 5, SchemaBound level 2) with TypeCompat (level 3: "operand types compatible"). Adds `Typedqliser.ABI.TypeCompat`, to the same quality bar: * a small SQL type universe (`SqlType`) and a typed column environment (`ColEnv`) with a total `lookupType` resolver, reusing the existing `Query`/`Pred`/`Value` AST; * `ValueCompat`/`PredTypeCompat`/`QueryTypeCompat` — the proposition that every WHERE comparison compares a column against a value of a matching type (a bound parameter adopts the column's type; a literal is TInt; a raw splice is TText). There is no constructor for a type clash, so a mismatched comparison is uninhabited; * `decQueryTypeCompat` — a sound + complete `Dec`, so a "Proven" TypeCompat certificate is backed by a constructive witness and a type clash can never be certified; * `certifyTypeCompatSound` (a `Proven` verdict provably entails the property); `typeCompatIsLevelThree : levelNat TypeCompat = 3`; * positive control (a well-typed query, with the certifier computing to `Proven`) and negative control (`name : Text` compared to an integer literal provably cannot be certified). Verified with idris2 0.7.0: `idris2 --build typedqliser-abi.ipkg` exits 0 with zero warnings (all 7 modules). Adversarially checked — three deliberately-false proofs (wrong level ordinal, a TInt literal certified against a TText column, and a type-compatible witness for the clash query) are all rejected by the type checker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx * abi: add Layer-3 NullSafe (level 4) theorem with guard discovery Adds Typedqliser.ABI.Invariants, a second, deeper, distinct machine-checked property over the existing Semantics query model (Query/Pred/Value reused verbatim). Where the Layer-2 flagship (Semantics.InjectionFree, level 5) is a purely structural property, NullSafe (level 4) is context-sensitive: a projected nullable column is safe only if the WHERE predicate guards it, with guards discovered by union under And and intersection under Or (disjunctive weakening). Includes a sound + complete decision procedure (decQueryNullSafe : Dec ...), a certifier proven sound (certifyNullSafeSound), the level-ordinal identity plus a proof it differs from InjectionFree, three positive controls and three non-vacuity controls (unguarded projection, And/union, Or/intersection). Builds clean with zero warnings; the deliberately-false adversarial proof is rejected. No believe_me/postulate/assert_total/%hint; %default total throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx * Add Layer-4 ABI<->FFI seam proof (Typedqliser.ABI.FfiSeam) Prove the FFI result-code encoding is SOUND: the C integer the Zig FFI returns faithfully round-trips back to the ABI value, and distinct ABI outcomes never collide on the wire. - intToResult / intToStatus: total decoders (if x == n over boolean Bits32 ==, which reduces on concrete literals). - resultRoundTrip / statusRoundTrip: lossless encoding, proved by Refl. - resultToIntInjective / statusToIntInjective: injectivity DERIVED from the round-trip via a local justInj + cong. - Positive controls (decodeOk/decodeNullPointer/decodeUnknown/decodeProven) and machine-checked non-vacuity controls (okNotError, schemaNotNull, provenNotRefuted) refuting collisions of distinct codes. Genuine total proof: no believe_me / postulate / assert_total / sorry. Builds clean with zero warnings; a false seam claim is rejected by --check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx * abi(capstone): Layer-5 end-to-end ABI soundness certificate Assemble the existing per-layer proofs into one inhabited record `ABISound` and a single value `abiContractDischarged` built from the already-exported witnesses: - Layer-2 flagship: safeQueryInjectionFree (InjectionFree, level 5) - Layer-2 companions: boundQuerySchemaBound (SchemaBound, level 2), goodQueryTypeCompat (TypeCompat, level 3) - Layer-3 invariant: guardedQueryNullSafe (NullSafe, level 4) - Layer-4 FFI seam: resultToIntInjective The capstone proves no new domain theorem; its content is that the whole chain holds simultaneously — if any prior layer were unsound the value would not typecheck. Adversarial control: a false certificate (deriving Ok = Error through the seam) is rejected by the typechecker. %default total, SPDX MPL-2.0, zero warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx * ci: make CI green — bump rust-ci to standards@8dc2bf0 (toolchain: stable fix); port ABI-FFI gate Python->Bash (Python is estate-banned) Resolves the standing baseline CI reds (rust-ci toolchain error, governance Language/anti-pattern, governance workflow-lint) without altering the proven ABI. The Bash gate reproduces the former Python gate's verdict verbatim (validated across all -iser repos) and catches the same drift classes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx * ci: adopt canonical Julia ABI-FFI gate (estate standard, matches verisimiser) in place of the interim Bash port * style: cargo fmt + clippy --fix to satisfy rust-ci (fmt --check + clippy -D warnings) * style: cargo fmt + clippy --fix under stable 1.96 (CI toolchain) — fmt --check + clippy -D warnings clean * feat(sql): resolve table aliases in L2/L3/L4 checks Aliased column references like `u.id` in `FROM users u` were not resolved to their real table, so the schema-binding (L2), type-compatibility (L3), and null-safety (L4) checks mishandled them: L2 raised false positives on valid aliased queries, while L3/L4 silently skipped aliased columns (false negatives). Build a qualifier->table map from the FROM/JOIN clauses and resolve qualifiers through it across all three levels, including alias-qualified projections in the null check. Strengthens the previously no-op l2_valid_multi_table_join test and adds L2/L3/L4 alias-resolution tests. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 280dcb5 commit 5bda716

2 files changed

Lines changed: 158 additions & 15 deletions

File tree

src/plugins/sql.rs

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,62 @@ impl SqlPlugin {
103103
}
104104
}
105105

106+
/// Build a qualifier→real-table map for the FROM/JOIN clauses of a query.
107+
/// Each real table maps to itself, and each alias (e.g. `u` in
108+
/// `FROM users u`) maps to its real table, so later passes can resolve a
109+
/// qualified reference like `u.id` to the `users` table.
110+
fn extract_table_aliases(statement: &Statement) -> Vec<(String, String)> {
111+
let mut aliases = Vec::new();
112+
match statement {
113+
Statement::Query(query) => {
114+
if let SetExpr::Select(select) = query.body.as_ref() {
115+
for twj in &select.from {
116+
Self::collect_aliases_from_join(twj, &mut aliases);
117+
}
118+
}
119+
}
120+
Statement::Delete(delete) => match &delete.from {
121+
sqlparser::ast::FromTable::WithFromKeyword(twjs)
122+
| sqlparser::ast::FromTable::WithoutKeyword(twjs) => {
123+
for twj in twjs {
124+
Self::collect_aliases_from_join(twj, &mut aliases);
125+
}
126+
}
127+
},
128+
Statement::Update { table, .. } => Self::collect_aliases_from_join(table, &mut aliases),
129+
_ => {}
130+
}
131+
aliases
132+
}
133+
134+
fn collect_aliases_from_join(twj: &TableWithJoins, aliases: &mut Vec<(String, String)>) {
135+
Self::collect_alias_from_factor(&twj.relation, aliases);
136+
for join in &twj.joins {
137+
Self::collect_alias_from_factor(&join.relation, aliases);
138+
}
139+
}
140+
141+
fn collect_alias_from_factor(factor: &TableFactor, aliases: &mut Vec<(String, String)>) {
142+
if let TableFactor::Table { name, alias, .. } = factor {
143+
let real = name.to_string().to_lowercase();
144+
aliases.push((real.clone(), real.clone()));
145+
if let Some(alias) = alias {
146+
aliases.push((alias.name.value.to_lowercase(), real));
147+
}
148+
}
149+
}
150+
151+
/// Resolve a table qualifier (a real table name or an alias) to its real
152+
/// table name. An unknown qualifier falls back to itself, so a later schema
153+
/// lookup still flags it rather than silently passing.
154+
fn resolve_qualifier(aliases: &[(String, String)], qualifier: &str) -> String {
155+
aliases
156+
.iter()
157+
.find(|(q, _)| q == qualifier)
158+
.map(|(_, t)| t.clone())
159+
.unwrap_or_else(|| qualifier.to_string())
160+
}
161+
106162
/// Extract all column references from a statement.
107163
fn extract_column_refs(statement: &Statement) -> Vec<(Option<String>, String)> {
108164
let mut cols = Vec::new();
@@ -172,12 +228,13 @@ impl SqlPlugin {
172228
right: &Expr,
173229
schema: &Schema,
174230
tables_in_query: &[String],
231+
aliases: &[(String, String)],
175232
) -> Vec<TypeIssue> {
176233
let mut issues = Vec::new();
177234

178235
// Get types of left and right if we can resolve them
179-
let left_type = Self::infer_expr_type(left, schema, tables_in_query);
180-
let right_type = Self::infer_expr_type(right, schema, tables_in_query);
236+
let left_type = Self::infer_expr_type(left, schema, tables_in_query, aliases);
237+
let right_type = Self::infer_expr_type(right, schema, tables_in_query, aliases);
181238

182239
if let (Some(lt), Some(rt)) = (&left_type, &right_type) {
183240
let lt_cat = type_category(lt);
@@ -225,7 +282,12 @@ impl SqlPlugin {
225282
}
226283

227284
/// Attempt to infer the SQL type of an expression given the schema.
228-
fn infer_expr_type(expr: &Expr, schema: &Schema, tables_in_query: &[String]) -> Option<String> {
285+
fn infer_expr_type(
286+
expr: &Expr,
287+
schema: &Schema,
288+
tables_in_query: &[String],
289+
aliases: &[(String, String)],
290+
) -> Option<String> {
229291
match expr {
230292
Expr::Identifier(ident) => {
231293
let col_name = ident.value.to_lowercase();
@@ -240,7 +302,7 @@ impl SqlPlugin {
240302
None
241303
}
242304
Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
243-
let table_name = parts[0].value.to_lowercase();
305+
let table_name = Self::resolve_qualifier(aliases, &parts[0].value.to_lowercase());
244306
let col_name = parts[1].value.to_lowercase();
245307
if let Some(table) = schema.tables.iter().find(|t| t.name == table_name)
246308
&& let Some(col) = table.columns.iter().find(|c| c.name == col_name)
@@ -323,6 +385,7 @@ impl QueryLanguagePlugin for SqlPlugin {
323385
for stmt in &statements {
324386
// Check table references
325387
let table_refs = Self::extract_table_refs(stmt);
388+
let aliases = Self::extract_table_aliases(stmt);
326389
for table_name in &table_refs {
327390
if !schema.tables.iter().any(|t| t.name == *table_name) {
328391
issues.push(SchemaIssue {
@@ -334,10 +397,12 @@ impl QueryLanguagePlugin for SqlPlugin {
334397
// Check column references
335398
let col_refs = Self::extract_column_refs(stmt);
336399
for (table_qualifier, col_name) in &col_refs {
337-
let tables_to_check: Vec<&str> = if let Some(tq) = table_qualifier {
338-
vec![tq.as_str()]
400+
// Resolve an alias qualifier (`u`) to its real table (`users`);
401+
// an unqualified column is checked against every table in scope.
402+
let tables_to_check: Vec<String> = if let Some(tq) = table_qualifier {
403+
vec![Self::resolve_qualifier(&aliases, tq)]
339404
} else {
340-
table_refs.iter().map(|s| s.as_str()).collect()
405+
table_refs.clone()
341406
};
342407

343408
let found = tables_to_check.iter().any(|tn| {
@@ -378,13 +443,14 @@ impl QueryLanguagePlugin for SqlPlugin {
378443

379444
for stmt in &statements {
380445
let table_refs = Self::extract_table_refs(stmt);
446+
let aliases = Self::extract_table_aliases(stmt);
381447

382448
// Check WHERE clause binary operations for type compatibility
383449
if let Statement::Query(query) = stmt
384450
&& let SetExpr::Select(select) = query.body.as_ref()
385451
&& let Some(ref selection) = select.selection
386452
{
387-
Self::check_expr_types(selection, schema, &table_refs, &mut issues);
453+
Self::check_expr_types(selection, schema, &table_refs, &aliases, &mut issues);
388454
}
389455
}
390456

@@ -398,6 +464,7 @@ impl QueryLanguagePlugin for SqlPlugin {
398464

399465
for stmt in &statements {
400466
let table_refs = Self::extract_table_refs(stmt);
467+
let aliases = Self::extract_table_aliases(stmt);
401468

402469
// Check if SELECT includes nullable columns without COALESCE or IS NULL handling
403470
if let Statement::Query(q) = stmt
@@ -428,6 +495,29 @@ impl QueryLanguagePlugin for SqlPlugin {
428495
}
429496
}
430497
}
498+
// Alias-qualified projection (e.g. `u.email` in `FROM users u`):
499+
// resolve the qualifier so nullability is still checked.
500+
SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts))
501+
| SelectItem::ExprWithAlias {
502+
expr: Expr::CompoundIdentifier(parts),
503+
..
504+
} if parts.len() == 2 => {
505+
let table_name =
506+
Self::resolve_qualifier(&aliases, &parts[0].value.to_lowercase());
507+
let col_name = parts[1].value.to_lowercase();
508+
if let Some(table) = schema.tables.iter().find(|t| t.name == table_name)
509+
&& let Some(col) = table.columns.iter().find(|c| c.name == col_name)
510+
&& col.nullable
511+
{
512+
issues.push(NullIssue {
513+
message: format!(
514+
"Nullable column '{}' selected without COALESCE or null handling",
515+
col_name
516+
),
517+
column: col_name.clone(),
518+
});
519+
}
520+
}
431521
_ => {}
432522
}
433523
}
@@ -444,16 +534,18 @@ impl SqlPlugin {
444534
expr: &Expr,
445535
schema: &Schema,
446536
tables: &[String],
537+
aliases: &[(String, String)],
447538
issues: &mut Vec<TypeIssue>,
448539
) {
449540
match expr {
450541
Expr::BinaryOp { left, op, right } => {
451-
let new_issues = Self::check_binary_op_types(op, left, right, schema, tables);
542+
let new_issues =
543+
Self::check_binary_op_types(op, left, right, schema, tables, aliases);
452544
issues.extend(new_issues);
453-
Self::check_expr_types(left, schema, tables, issues);
454-
Self::check_expr_types(right, schema, tables, issues);
545+
Self::check_expr_types(left, schema, tables, aliases, issues);
546+
Self::check_expr_types(right, schema, tables, aliases, issues);
455547
}
456-
Expr::Nested(inner) => Self::check_expr_types(inner, schema, tables, issues),
548+
Expr::Nested(inner) => Self::check_expr_types(inner, schema, tables, aliases, issues),
457549
_ => {}
458550
}
459551
}

tests/integration_test.rs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,60 @@ fn l2_valid_multi_table_join() {
302302
&schema,
303303
)
304304
.unwrap();
305-
// Qualified columns use alias (u, p) which don't match schema table names directly.
306-
// This is expected — aliases need separate resolution logic.
307-
let _ = issues;
305+
// Aliases `u`/`p` resolve to `users`/`posts`; every column exists, so a
306+
// valid aliased join must produce no schema-binding issues.
307+
assert!(
308+
issues.is_empty(),
309+
"Aliased join over existing columns must pass L2. Got: {:?}",
310+
issues
311+
);
312+
}
313+
314+
#[test]
315+
fn l2_alias_resolves_unknown_column() {
316+
// The alias resolves to a real table, so a genuinely missing column on the
317+
// aliased table is still caught (no false negative from alias resolution).
318+
let plugin = get_plugin("sql").unwrap();
319+
let schema = test_schema();
320+
let issues = plugin
321+
.schema_check("SELECT u.nonexistent FROM users u", &schema)
322+
.unwrap();
323+
assert!(
324+
issues.iter().any(|i| i.message.contains("nonexistent")),
325+
"Missing column on an aliased table must be flagged. Got: {:?}",
326+
issues
327+
);
328+
}
329+
330+
#[test]
331+
fn l3_type_check_resolves_alias() {
332+
// `u.name` (text) compared to a numeric literal must be caught even though
333+
// the column is referenced through the alias `u`.
334+
let plugin = get_plugin("sql").unwrap();
335+
let schema = test_schema();
336+
let issues = plugin
337+
.type_check("SELECT u.id FROM users u WHERE u.name = 42", &schema)
338+
.unwrap();
339+
assert!(
340+
!issues.is_empty(),
341+
"Type mismatch on an alias-qualified column must be flagged"
342+
);
343+
}
344+
345+
#[test]
346+
fn l4_null_check_resolves_alias() {
347+
// `u.email` is nullable; selecting it through the alias `u` without null
348+
// handling must still raise a null-safety issue.
349+
let plugin = get_plugin("sql").unwrap();
350+
let schema = test_schema();
351+
let issues = plugin
352+
.null_check("SELECT u.email FROM users u", &schema)
353+
.unwrap();
354+
assert!(
355+
issues.iter().any(|i| i.column == "email"),
356+
"Nullable alias-qualified column must be flagged at L4. Got: {:?}",
357+
issues
358+
);
308359
}
309360

310361
#[test]

0 commit comments

Comments
 (0)