Skip to content

Commit 55a2ec3

Browse files
feat(sql): null-check SELECT * and recognise CTE names (L4/L2 soundness) (#42)
* 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5bda716 commit 55a2ec3

2 files changed

Lines changed: 94 additions & 8 deletions

File tree

src/plugins/sql.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ 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+
{
170+
for cte in &with.cte_tables {
171+
names.push(cte.alias.name.value.to_lowercase());
172+
}
173+
}
174+
names
175+
}
176+
162177
/// Extract all column references from a statement.
163178
fn extract_column_refs(statement: &Statement) -> Vec<(Option<String>, String)> {
164179
let mut cols = Vec::new();
@@ -386,8 +401,11 @@ impl QueryLanguagePlugin for SqlPlugin {
386401
// Check table references
387402
let table_refs = Self::extract_table_refs(stmt);
388403
let aliases = Self::extract_table_aliases(stmt);
404+
let cte_names = Self::extract_cte_names(stmt);
389405
for table_name in &table_refs {
390-
if !schema.tables.iter().any(|t| t.name == *table_name) {
406+
if !cte_names.contains(table_name)
407+
&& !schema.tables.iter().any(|t| t.name == *table_name)
408+
{
391409
issues.push(SchemaIssue {
392410
message: format!("Table '{}' not found in schema", table_name),
393411
});
@@ -518,6 +536,43 @@ impl QueryLanguagePlugin for SqlPlugin {
518536
});
519537
}
520538
}
539+
// Unqualified `*`: expand to every nullable column of
540+
// each table in scope (so `SELECT * FROM users` is
541+
// null-checked, not silently skipped).
542+
SelectItem::Wildcard(_) => {
543+
for table_name in &table_refs {
544+
if let Some(table) =
545+
schema.tables.iter().find(|t| t.name == *table_name)
546+
{
547+
for col in table.columns.iter().filter(|c| c.nullable) {
548+
issues.push(NullIssue {
549+
message: format!(
550+
"Nullable column '{}' selected via wildcard without COALESCE or null handling",
551+
col.name
552+
),
553+
column: col.name.clone(),
554+
});
555+
}
556+
}
557+
}
558+
}
559+
// Alias-qualified `u.*`: expand the resolved table only.
560+
SelectItem::QualifiedWildcard(obj, _) => {
561+
let table_name =
562+
Self::resolve_qualifier(&aliases, &obj.to_string().to_lowercase());
563+
if let Some(table) = schema.tables.iter().find(|t| t.name == table_name)
564+
{
565+
for col in table.columns.iter().filter(|c| c.nullable) {
566+
issues.push(NullIssue {
567+
message: format!(
568+
"Nullable column '{}' selected via wildcard without COALESCE or null handling",
569+
col.name
570+
),
571+
column: col.name.clone(),
572+
});
573+
}
574+
}
575+
}
521576
_ => {}
522577
}
523578
}

tests/integration_test.rs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -608,17 +608,48 @@ fn l4_nullable_comment_author() {
608608
}
609609

610610
#[test]
611-
fn l4_select_star_not_flagged() {
612-
// SELECT * doesn't produce individual Identifier expressions for each column,
613-
// so the null checker won't flag individual columns.
611+
fn l4_select_star_flags_nullable() {
612+
// `SELECT *` is expanded to the table's columns, so its nullable columns
613+
// (users.email, users.age) are flagged like an explicit selection would be.
614614
let plugin = get_plugin("sql").unwrap();
615615
let schema = test_schema();
616616
let issues = plugin.null_check("SELECT * FROM users", &schema).unwrap();
617-
// The current implementation only checks UnnamedExpr(Identifier), not Wildcard.
618-
// This test documents current behavior.
619617
assert!(
620-
issues.is_empty(),
621-
"SELECT * is not individually checked for null (current behavior)"
618+
issues.iter().any(|i| i.column == "email"),
619+
"SELECT * must flag nullable 'email'. Got: {:?}",
620+
issues
621+
);
622+
assert!(
623+
issues.iter().any(|i| i.column == "age"),
624+
"SELECT * must flag nullable 'age'. Got: {:?}",
625+
issues
626+
);
627+
// Non-nullable columns (id, name) must NOT be flagged.
628+
assert!(
629+
!issues
630+
.iter()
631+
.any(|i| i.column == "id" || i.column == "name"),
632+
"SELECT * must not flag non-nullable columns. Got: {:?}",
633+
issues
634+
);
635+
}
636+
637+
#[test]
638+
fn l2_cte_name_not_flagged_as_missing_table() {
639+
// A CTE name is a valid in-query table source, not a schema table, so it
640+
// must not be reported as "not found in schema".
641+
let plugin = get_plugin("sql").unwrap();
642+
let schema = test_schema();
643+
let issues = plugin
644+
.schema_check(
645+
"WITH recent AS (SELECT id FROM users) SELECT id FROM recent",
646+
&schema,
647+
)
648+
.unwrap();
649+
assert!(
650+
!issues.iter().any(|i| i.message.contains("recent")),
651+
"CTE name 'recent' must not be flagged as a missing table. Got: {:?}",
652+
issues
622653
);
623654
}
624655

0 commit comments

Comments
 (0)