Skip to content

Commit 5e92e58

Browse files
feat(sql): validate tables inside subqueries and derived tables (L2) (#43)
* 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. * feat(sql): null-check SELECT * + recognise CTE names (L4/L2 soundness) Two more soundness holes in the SQL safety levels: - L4 (null-safety): `SELECT *` / `u.*` were not expanded, so nullable columns selected via a wildcard were silently not flagged. Expand a wildcard to the in-scope table columns (resolving the alias for a qualified `u.*`) and flag the nullable ones. - L2 (schema-binding): a `WITH cte AS (...)` name referenced in FROM was reported as 'table not found', a false positive. Collect CTE names and exclude them from the table-existence check. Updates l4_select_star (was a no-op documenting the gap) to assert the nullable columns are now flagged, and adds an L2 CTE test. * feat(sql): validate tables inside subqueries and derived tables (L2) Schema-binding (L2) only checked the outermost FROM, so a missing table in a WHERE ... IN (SELECT ...) subquery, a derived table, a CTE body, or a set operation went undetected (false negative). Add a recursive walk that collects every real table source across all scopes plus the non-schema sources (CTE names, derived-table aliases) to exclude, and use it for the L2 table-existence check. Column resolution stays scoped to the top level to avoid correlated-reference false positives. Adds tests for a missing table in a subquery and in a derived table, and a no-false-positive test for a valid subquery. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55a2ec3 commit 5e92e58

2 files changed

Lines changed: 193 additions & 13 deletions

File tree

src/plugins/sql.rs

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,133 @@ impl SqlPlugin {
159159
.unwrap_or_else(|| qualifier.to_string())
160160
}
161161

162-
/// Names introduced by a `WITH` clause. These act as table sources within
163-
/// the query but are not part of the schema, so they must not be flagged as
164-
/// "table not found".
165-
fn extract_cte_names(statement: &Statement) -> Vec<String> {
166-
let mut names = Vec::new();
167-
if let Statement::Query(query) = statement
168-
&& let Some(with) = &query.with
169-
{
162+
/// Recursively collect every real table referenced anywhere in the
163+
/// statement — top level, JOINs, CTE bodies, derived tables, and subqueries
164+
/// in `WHERE`/`HAVING`/projection — alongside the "non-schema" sources that
165+
/// must not be validated against the schema (CTE names and derived-table
166+
/// aliases). Lets L2 catch a missing table inside a subquery, not just the
167+
/// outermost `FROM`.
168+
fn extract_all_table_sources(statement: &Statement) -> (Vec<String>, Vec<String>) {
169+
let mut real = Vec::new();
170+
let mut nonschema = Vec::new();
171+
match statement {
172+
Statement::Query(query) => Self::walk_query(query, &mut real, &mut nonschema),
173+
Statement::Insert(insert) => {
174+
real.push(insert.table_name.to_string().to_lowercase());
175+
if let Some(source) = &insert.source {
176+
Self::walk_query(source, &mut real, &mut nonschema);
177+
}
178+
}
179+
Statement::Update { table, .. } => Self::walk_twj(table, &mut real, &mut nonschema),
180+
Statement::Delete(delete) => match &delete.from {
181+
sqlparser::ast::FromTable::WithFromKeyword(twjs)
182+
| sqlparser::ast::FromTable::WithoutKeyword(twjs) => {
183+
for twj in twjs {
184+
Self::walk_twj(twj, &mut real, &mut nonschema);
185+
}
186+
}
187+
},
188+
_ => {}
189+
}
190+
real.sort();
191+
real.dedup();
192+
(real, nonschema)
193+
}
194+
195+
fn walk_query(query: &Query, real: &mut Vec<String>, nonschema: &mut Vec<String>) {
196+
if let Some(with) = &query.with {
170197
for cte in &with.cte_tables {
171-
names.push(cte.alias.name.value.to_lowercase());
198+
nonschema.push(cte.alias.name.value.to_lowercase());
199+
Self::walk_query(&cte.query, real, nonschema);
172200
}
173201
}
174-
names
202+
Self::walk_setexpr(query.body.as_ref(), real, nonschema);
203+
}
204+
205+
fn walk_setexpr(set: &SetExpr, real: &mut Vec<String>, nonschema: &mut Vec<String>) {
206+
match set {
207+
SetExpr::Select(select) => {
208+
for twj in &select.from {
209+
Self::walk_twj(twj, real, nonschema);
210+
}
211+
if let Some(sel) = &select.selection {
212+
Self::walk_expr_subqueries(sel, real, nonschema);
213+
}
214+
for item in &select.projection {
215+
if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } =
216+
item
217+
{
218+
Self::walk_expr_subqueries(e, real, nonschema);
219+
}
220+
}
221+
}
222+
SetExpr::Query(q) => Self::walk_query(q, real, nonschema),
223+
SetExpr::SetOperation { left, right, .. } => {
224+
Self::walk_setexpr(left, real, nonschema);
225+
Self::walk_setexpr(right, real, nonschema);
226+
}
227+
_ => {}
228+
}
229+
}
230+
231+
fn walk_twj(twj: &TableWithJoins, real: &mut Vec<String>, nonschema: &mut Vec<String>) {
232+
Self::walk_factor(&twj.relation, real, nonschema);
233+
for join in &twj.joins {
234+
Self::walk_factor(&join.relation, real, nonschema);
235+
}
236+
}
237+
238+
fn walk_factor(factor: &TableFactor, real: &mut Vec<String>, nonschema: &mut Vec<String>) {
239+
match factor {
240+
TableFactor::Table { name, .. } => real.push(name.to_string().to_lowercase()),
241+
TableFactor::Derived {
242+
subquery, alias, ..
243+
} => {
244+
if let Some(a) = alias {
245+
nonschema.push(a.name.value.to_lowercase());
246+
}
247+
Self::walk_query(subquery, real, nonschema);
248+
}
249+
TableFactor::NestedJoin {
250+
table_with_joins, ..
251+
} => Self::walk_twj(table_with_joins, real, nonschema),
252+
_ => {}
253+
}
254+
}
255+
256+
/// Descend an expression to find nested subqueries (we only need their
257+
/// table sources here, not their columns).
258+
fn walk_expr_subqueries(expr: &Expr, real: &mut Vec<String>, nonschema: &mut Vec<String>) {
259+
match expr {
260+
Expr::Subquery(q) | Expr::Exists { subquery: q, .. } => {
261+
Self::walk_query(q, real, nonschema)
262+
}
263+
Expr::InSubquery {
264+
expr: inner,
265+
subquery,
266+
..
267+
} => {
268+
Self::walk_expr_subqueries(inner, real, nonschema);
269+
Self::walk_query(subquery, real, nonschema);
270+
}
271+
Expr::InList {
272+
expr: inner, list, ..
273+
} => {
274+
Self::walk_expr_subqueries(inner, real, nonschema);
275+
for e in list {
276+
Self::walk_expr_subqueries(e, real, nonschema);
277+
}
278+
}
279+
Expr::BinaryOp { left, right, .. } => {
280+
Self::walk_expr_subqueries(left, real, nonschema);
281+
Self::walk_expr_subqueries(right, real, nonschema);
282+
}
283+
Expr::Nested(e)
284+
| Expr::IsNull(e)
285+
| Expr::IsNotNull(e)
286+
| Expr::UnaryOp { expr: e, .. } => Self::walk_expr_subqueries(e, real, nonschema),
287+
_ => {}
288+
}
175289
}
176290

177291
/// Extract all column references from a statement.
@@ -401,9 +515,12 @@ impl QueryLanguagePlugin for SqlPlugin {
401515
// Check table references
402516
let table_refs = Self::extract_table_refs(stmt);
403517
let aliases = Self::extract_table_aliases(stmt);
404-
let cte_names = Self::extract_cte_names(stmt);
405-
for table_name in &table_refs {
406-
if !cte_names.contains(table_name)
518+
// Validate table existence across every scope (including subqueries
519+
// and derived tables), excluding non-schema sources (CTE names and
520+
// derived-table aliases).
521+
let (all_tables, nonschema_sources) = Self::extract_all_table_sources(stmt);
522+
for table_name in &all_tables {
523+
if !nonschema_sources.contains(table_name)
407524
&& !schema.tables.iter().any(|t| t.name == *table_name)
408525
{
409526
issues.push(SchemaIssue {

tests/integration_test.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,69 @@ fn l2_cte_name_not_flagged_as_missing_table() {
653653
);
654654
}
655655

656+
#[test]
657+
fn l2_subquery_missing_table_flagged() {
658+
// A missing table inside a `WHERE ... IN (SELECT ...)` subquery must be
659+
// caught, not only the outermost FROM.
660+
let plugin = get_plugin("sql").unwrap();
661+
let schema = test_schema();
662+
let issues = plugin
663+
.schema_check(
664+
"SELECT id FROM users WHERE id IN (SELECT user_id FROM nonexistent_table)",
665+
&schema,
666+
)
667+
.unwrap();
668+
assert!(
669+
issues
670+
.iter()
671+
.any(|i| i.message.contains("nonexistent_table")),
672+
"Missing table inside a subquery must be flagged. Got: {:?}",
673+
issues
674+
);
675+
}
676+
677+
#[test]
678+
fn l2_valid_subquery_no_false_positive() {
679+
// Both the outer and the subquery tables exist, so no table-binding issue.
680+
let plugin = get_plugin("sql").unwrap();
681+
let schema = test_schema();
682+
let issues = plugin
683+
.schema_check(
684+
"SELECT id FROM users WHERE id IN (SELECT user_id FROM posts)",
685+
&schema,
686+
)
687+
.unwrap();
688+
assert!(
689+
!issues.iter().any(|i| i.message.contains("not found")),
690+
"Valid subquery over existing tables must not raise a binding issue. Got: {:?}",
691+
issues
692+
);
693+
}
694+
695+
#[test]
696+
fn l2_derived_table_validated() {
697+
// A derived table's inner FROM is validated (bogus_table flagged), while
698+
// the derived-table alias `sub` is a non-schema source and is not flagged.
699+
let plugin = get_plugin("sql").unwrap();
700+
let schema = test_schema();
701+
let issues = plugin
702+
.schema_check(
703+
"SELECT sub.x FROM (SELECT id FROM bogus_table) sub",
704+
&schema,
705+
)
706+
.unwrap();
707+
assert!(
708+
issues.iter().any(|i| i.message.contains("bogus_table")),
709+
"Missing table inside a derived table must be flagged. Got: {:?}",
710+
issues
711+
);
712+
assert!(
713+
!issues.iter().any(|i| i.message.contains("'sub'")),
714+
"Derived-table alias 'sub' must not be flagged as a missing table. Got: {:?}",
715+
issues
716+
);
717+
}
718+
656719
// ============================================================================
657720
// End-to-end: Full query -> manifest -> check pipeline
658721
// ============================================================================

0 commit comments

Comments
 (0)