Skip to content

fix: stop DML binder paths from silently losing writes and reads#72

Open
takaebato wants to merge 6 commits into
masterfrom
audit-dml-write-loss
Open

fix: stop DML binder paths from silently losing writes and reads#72
takaebato wants to merge 6 commits into
masterfrom
audit-dml-write-loss

Conversation

@takaebato

@takaebato takaebato commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

Six related binder bugs, all making UPDATE / INSERT / SELECT INTO surfaces silently incomplete, plus the first dialect-semantics mechanism.

  • A qualified SET target whose qualifier named no writable relation dropped the whole assignment. UPDATE t SET address.city = old_city || '-x' lost the RHS reads, the lineage, and — as the sole assignment — the entire UPDATE from writes and the CRUD buckets, with no diagnostic. The dotted path now resolves by dialect: in struct-capable dialects (PostgreSQL family — whose SET grammar forbids relation qualifiers outright, so the leading segment is always a column, verified on PostgreSQL 18 — plus BigQuery / Hive / DuckDB / Generic) it reads as a struct subfield path on the single sink, at any depth (SET address.city and SET address.city.zip both write t.address; a leading segment that addresses the root in scope — the alias when aliased, the bare name otherwise — is a stripped prefix, and a shadowed name is a column: UPDATE t AS x SET t.a writes column t, exactly as PostgreSQL 18 reads it); in table-qualifier-only dialects (MySQL / MSSQL / SQLite / ClickHouse, where such a qualifier can only be a mistyped table path), among several writable relations, and for unrepresentable paths it surfaces unattributed (table: None, Unresolved). The assignment is never discarded. The tuple form inherits the fix, which also restores its reads/lineage symmetry.
  • Dialect-semantics mechanism: the binder now holds the parsing dialect, and each dialect-dependent resolution rule is a question method in the new binder/dialect.rs concern file (supports_struct_set_targets() is the first). Bind code never downcasts the dialect ad hoc; a future dialect-dependent rule is one more method there.
  • T-SQL UPDATE <alias> ... FROM t AS <alias> fabricated a phantom write target. The bare target name bound as a table a, so writes pinned a nonexistent relation while a.id became ambiguous between the phantom and the real one. When the join-less bare target names a FROM alias, the FROM relations now bind first (mirroring DELETE's USING order) and the target resolves through them: writes: t.x, a.id reads t.id.
  • SELECT INTO buried a data-modifying CTE's write. WITH c AS (INSERT INTO t1 ... RETURNING a) SELECT a INTO t2 FROM c wrapped the CreateTableAs around the statement's WITH, hiding the CTE's INSERT inside the root's input where the write-root collection never descends — t1's write vanished from writes, CRUD, and lineage. The WITH now stays outermost, as PostgreSQL's top-level-only rule for data-modifying CTEs implies.
  • PG's INSERT INTO counters AS c upsert alias was ignored, leaving the conflict action's c.n unresolved (no sink read, no lineage source). The alias now rides the conflict and RETURNING scopes.
  • UPDATE ... FROM / DELETE ... USING loops dropped merge columns. The joined relations were kept but their USING / NATURAL merge columns were not, so an unqualified reference to one stayed Ambiguous instead of fanning in to the joined sides as the same join does in a SELECT A parenthesized-join MERGE source had the same drop, one site over — fixed alike.
  • MySQL UPDATE ... ORDER BY keys were never bound (DELETE's were), losing their reads.

Tests

  • New pins for each: struct subfield targets (plain, root-prefixed, nested-depth, aliased-target shadowing, tuple, and the MySQL unattributed counterpart), the unattributed multi-writable case, the T-SQL alias form, the SELECT INTO CTE write, the upsert target alias, the UPDATE FROM and MERGE-source fan-ins, and the ORDER BY reads. One existing pin updated: a 5-segment SET target now attributes to the leading column instead of vanishing. Full workspace: 866 tests, clippy and rustdoc clean.

🤖 Generated with Claude Code

Six related binder bugs, all making UPDATE / INSERT / SELECT INTO
surfaces silently incomplete:

- A qualified SET target whose qualifier named no writable relation
  dropped the whole assignment - its RHS reads, its lineage, and (for
  a sole assignment) the entire UPDATE from the write surfaces. It now
  reads as a PostgreSQL composite subfield on a single sink
  (SET address.city = ... writes t.address, root-prefixed spelling
  included) and surfaces unattributed (table: None, Unresolved) among
  several writable relations or for unrepresentable paths. The tuple
  form inherits the fix, restoring its reads/lineage symmetry.
- T-SQL UPDATE <alias> ... FROM t AS <alias> fabricated a phantom
  write target named after the alias and made the alias's own
  references ambiguous. The FROM relations now bind first (mirroring
  DELETE's USING order) and the target resolves through them.
- SELECT INTO wrapped its CreateTableAs around the statement's WITH,
  burying a data-modifying CTE's INSERT inside the root's input where
  the write-root collection never descends; the CTE's write vanished
  from every surface. The WITH now stays outermost.
- PG's INSERT INTO t AS c upsert alias was ignored, leaving the
  conflict action's c.n references unresolved; it now rides the
  conflict and RETURNING scopes.
- The UPDATE FROM / DELETE USING loops kept joined relations but
  dropped their USING / NATURAL merge columns, so an unqualified
  reference to one stayed Ambiguous instead of fanning in as in a
  SELECT.
- MySQL UPDATE ... ORDER BY keys were never bound (DELETE's were).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ PR title follows the Conventional Commits spec.

@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.83871% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.21%. Comparing base (97132af) to head (5246254).

Files with missing lines Patch % Lines
sql-insight/src/resolver/binder/statement.rs 94.69% 4 Missing and 3 partials ⚠️
sql-insight/src/extractor/crud_table_extractor.rs 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #72      +/-   ##
==========================================
+ Coverage   95.19%   95.21%   +0.01%     
==========================================
  Files          27       28       +1     
  Lines        6179     6307     +128     
  Branches     6179     6307     +128     
==========================================
+ Hits         5882     6005     +123     
- Misses        206      209       +3     
- Partials       91       93       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

takaebato and others added 5 commits July 18, 2026 16:38
The struct reading of an unmatched SET qualifier is exact for
PostgreSQL-family dialects (their SET grammar forbids relation
qualifiers, so the leading segment is always a column) but wrong for
MySQL / MSSQL / SQLite / ClickHouse, where no struct columns exist and
such a qualifier can only be a mistyped table path - the previous
structural heuristic fabricated a column write there.

Introduces DialectCapabilities, resolution-rule switches derived once
from the parsing dialect and threaded into the binder alongside the
identifier style (the first dialect-dependent resolution rule; new
ones become a one-line switch). Table-qualifier-only dialects now
surface the unmatched target unattributed (table: None, Unresolved);
struct-capable dialects - and GenericDialect, where struct-field SET
syntax almost always carries PostgreSQL-family intent - keep the
struct reading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilities struct

Replace the precomputed DialectCapabilities bool-field struct with the
dialect itself on the Binder: extraction threads `&dyn Dialect` down to
build_with_diagnostics, and each dialect-dependent resolution rule is a
question method in the new binder/dialect.rs concern file
(struct_set_targets() carries the engine-verified rationale). Bind code
never downcasts the dialect ad hoc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match the supports_* naming of sqlparser's own Dialect capability
methods, which this file extends in spirit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The source-scope merge kept only the relations, dropping a
parenthesized join's USING / NATURAL merge columns — the same bug just
fixed in the UPDATE ... FROM and DELETE ... USING loops, one site over.
An unqualified reference to the merge column now fans in like the same
join in a SELECT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two corners of the unmatched-qualifier fallback, both verified against
PostgreSQL 18. Root-prefix detection compared the leading segment
against the target's name textually, so under an aliased target
(UPDATE t AS x) the shadowed name neither stripped nor read as a
struct column and the sole assignment fell unattributed, erasing the
UPDATE from the table-level write surface; the check is now scope
addressability (the alias when aliased, the bare name otherwise), and
a shadowed leading segment is a struct column, exactly as PostgreSQL
reads it. And the struct reading was capped at two segments, leaving
a nested path (SET address.city.zip) unattributed; PostgreSQL puts no
bound on the path depth, so neither do we — the leading column is
written whatever the depth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant