feat!: attribute unqualified SET targets with the read-side rules - #59
Merged
Merged
Conversation
Contributor
|
✅ PR title follows the Conventional Commits spec. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #59 +/- ##
==========================================
+ Coverage 94.77% 94.85% +0.08%
==========================================
Files 27 27
Lines 5686 5737 +51
Branches 5686 5737 +51
==========================================
+ Hits 5389 5442 +53
+ Misses 218 216 -2
Partials 79 79 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
takaebato
force-pushed
the
feat/write-side-unqualified-attribution
branch
2 times, most recently
from
July 4, 2026 13:48
30eea8d to
c941784
Compare
An unqualified SET column in a multi-table UPDATE used to pin the root (first) table unconditionally — a heuristic with no textual basis (MySQL's first table is just a join operand; the SET decides). It now resolves with the same candidate/pick rules as a read, over the statement's *writable* relations: the sole catalog-confirmed owner pins that table (so `SET a = a + 1` writes the table its own read resolves to), several candidates surface `Ambiguous` and none `Unresolved` — `table: None`, like an unattributed read, contributing no table-level write. Real MySQL agrees: an unqualified column owned by several joined tables is an error (1052), so no write target exists to pin. The mirror applies only where there is a genuine choice: with zero or one writable relation the statement itself names the sink, so a single-table UPDATE, MERGE SET, ON CONFLICT SET, and PostgreSQL/T-SQL `UPDATE t … FROM u` (FROM relations are readable, never writable) keep pinning the root unconditionally. The full attribution matrix lives on `resolve_assignment_column`. Breaking: `ColumnWrite`'s documented contract allowed only `Cataloged`/`Inferred`; an unqualified multi-table SET can now surface `Ambiguous`/`Unresolved` with `table: None`, and such writes no longer appear in `update_tables`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
takaebato
force-pushed
the
feat/write-side-unqualified-attribution
branch
from
July 5, 2026 03:22
c941784 to
a53b8c7
Compare
Merged
takaebato
added a commit
that referenced
this pull request
Jul 5, 2026
) ## Summary Implements the remaining inline-view deferral from #58 (design agreed in discussion): an Oracle **join-view** INSERT now resolves to its base table by **column attribution**, instead of dropping the whole statement. ```sql INSERT INTO (SELECT e.id, e.name FROM emp e JOIN dept d ON e.dept_id = d.id WHERE d.active = 1) VALUES (100, 'x') -- before: everything empty + UnsupportedStatement -- after: create=[emp] read=[emp, dept] -- writes=[emp.id, emp.name] -- reads=[emp.dept_id, dept.id, dept.active] (ON + WHERE, filter) ``` ## Attribution rule (composes two existing mechanisms — no new rules) The row lands in the one relation **every** projected column agrees on: | projection column | attribution | precedent | |---|---|---| | qualified (`e.id`) | its qualifier, resolved against the view's relations — text-only, no catalog | multi-table UPDATE's `SET t2.col` | | unqualified (`name`) | the catalog-owner rule (`unqualified_write_binding`, #59) over the view's relations | #59's read-mirrored SET attribution | Any column ambiguous / unresolved / owned by a different relation than its siblings — or any factor naming a declared CTE (not a base table) — → drop + flag as before. Real Oracle rejects those shapes too (the INTO columns must all belong to one key-preserved table); key-preservedness itself isn't *verified* — that needs unique-key metadata the catalog doesn't carry — attribution assumes a valid statement. The classic comma-join spelling (`FROM emp e, dept d WHERE …`) resolves identically. ## Companion relations: `Insert::target_context` The joined tables other than the target (here `dept`) become scanned context: - **table reads** ✓ (`children()` now includes the context, so the scan surfaces; `emp` itself keeps the sink rule — it reads because its columns are referenced, not via a scan, consistent with the single-table view path) - **no data feed** ✓ — table lineage's `feeding_scans` walks `input` only, matching the semantics (the join gates which rows are visible; the value path is the source) - ON + WHERE land in the existing `target_predicate` (filter reads over the full view scope, aliases included) `TableReference`'s `TryFrom<&Insert>` stays **shape-determined**: the gate admits joins of plain tables, but a join view's base table needs binder resolution, so the plain identity parse resolves single-table views only (documented; join → `Err`). ## Edge cases covered on review - **CTE factors**: any view factor naming a declared CTE — target (used to fabricate a write to the CTE) or companion (used to surface the CTE name as a phantom base-table read) — now flags the whole statement, uniformly in the factor loop. A WITH *inside* the view stays gate-rejected; a top-level CTE consumed only by the source still traces through its body (pinned). - **USING / NATURAL join views** resolve, and the USING clause itself adds no reads — consistent with the SELECT path, where reads come from *reference* sites (pinned). - **Self-join views** resolve to the one table (identity is right; key-preservedness is per-instance and unverifiable without key metadata). - **Table lineage**: the companion never feeds (`s → emp` only, no `dept → emp`) — pinned at table level. - Gate rejections pinned: FROM-less view, derived factor (single and joined), wildcard / expression projections, ClickHouse `TABLE FUNCTION` targets (moved code) incl. an unrepresentable function name. - For full patch coverage, the gate hands its proof over **entirely as data** (`InsertTargetView { factors, projection, join_operators, selection }` — parse-don't-validate): the unreachable defensive arms are *deleted*, and no consumer re-reads the `Query`, so `factors` can't be paired with a clause it wasn't derived from. ## Tests Qualified projection (catalog-free), comma form, SELECT-source lineage through the view (`s.x → emp.id`), straddling columns → flag, unqualified without a catalog → flag, unqualified **with** a catalog attributing by owner (Oracle-cased catalog), table-level companion read + lineage, USING / self-join / gate-rejection pins, all CTE placements (factor → flag, inside the view → flag, top-level source-only → traces through the body), and the #58 flag test narrowed to an undetermined shape. All gates green (fmt / clippy / `test --all` / doc); 788 tests, **100% patch coverage** on source lines (llvm-cov ∩ diff). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
takaebato
added a commit
that referenced
this pull request
Jul 5, 2026
## Summary The changelog body template credits ` by @author` on every normal group entry but not on the "Breaking Changes" `####` headings. This release will be the first to render that section (#55 / #59 are `feat!`), so add the same guarded attribution there: ```jinja #### … {{ commit.message }}{% if commit.remote.username %} by @{{ commit.remote.username }}{% endif %} ``` Also drops CLAUDE.md's stale breaking-changes bullet: it still described the PR-description changelog block dropped in #56, the `!` flag is already covered by the PR-title bullet, and the rendering / hand-written-migration- notes facts live at their source in release-plz.toml's comments. ## Verification `commit.remote.username` is the same variable the normal-entry line two lines below already uses, under the same guard. Rendering can't be verified locally without GitHub-integrated release-plz — after this merges, release-plz will regenerate the open release PR (#48), where the Breaking Changes headings can be eyeballed before releasing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
takaebato
added a commit
that referenced
this pull request
Jul 5, 2026
The CLI gained a real feature (#53: binstall, completions, man pages), so promote release-plz's 0.x feat→patch default to a minor bump. Add concise migration notes under the library's Breaking Changes headings (#59 SET attribution, #55 sqlparser 0.62). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
takaebato
added a commit
that referenced
this pull request
Jul 5, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
takaebato
added a commit
that referenced
this pull request
Jul 5, 2026
## 🤖 New release * `sql-insight`: 0.3.0 -> 0.4.0 (✓ API compatible changes) * `sql-insight-cli`: 0.2.1 -> 0.2.2 <details><summary><i><b>Changelog</b></i></summary><p> ## `sql-insight` <blockquote> ## [0.4.0](sql-insight-v0.3.0...sql-insight-v0.4.0) - 2026-07-05 ###⚠️ Breaking Changes #### attribute unqualified SET targets with the read-side rules ([#59](#59)) by @takaebato #### upgrade sqlparser to 0.62 ([#55](#55)) by @takaebato ### Added - resolve Oracle join-view INSERT targets by column attribution ([#61](#61)) by @takaebato - fan out multi-column-alias lineage to every alias ([#60](#60)) by @takaebato - resolve Oracle inline-view INSERT targets to their base table ([#58](#58)) by @takaebato ### Fixed - don't surface a ClickHouse ARRAY JOIN operand as a table read ([#57](#57)) by @takaebato ### Other Changes - changelog breaking-change workflow, version-bump docs, and keywords ([#51](#51)) by @takaebato - tidy keywords, README versions, and add a version-sync check ([#46](#46)) by @takaebato </blockquote> ## `sql-insight-cli` <blockquote> ## [0.2.2](sql-insight-cli-v0.2.1...sql-insight-cli-v0.2.2) - 2026-07-05 ### Added - *(cli)* prebuilt binary distribution — cargo binstall, completions, man, and provenance ([#53](#53)) by @takaebato ### Fixed - *(deps)* update rust crate clap_mangen to 0.3 ([#54](#54)) by @renovate[bot] ### Other Changes - changelog breaking-change workflow, version-bump docs, and keywords ([#51](#51)) by @takaebato - tidy keywords, README versions, and add a version-sync check ([#46](#46)) by @takaebato </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Takahiro Ebato <takahiro.ebato@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Organizes and fixes write-target attribution for unqualified SET columns
(follow-up noted while reviewing #58; design agreed in discussion).
Previously,
UPDATE t1 JOIN t2 ON … SET a = 1attributed the write to theroot (first) table unconditionally — even when a catalog said only
t2owns
a(the read side would resolve the sameatot2, soSET a = a + 1readt2.abut "wrote"t1.a). The root pin had no textualbasis: MySQL's first table is just a join operand, and real MySQL rejects the
ambiguous case outright (error 1052), so no write target ever exists to pin.
Now the unqualified target resolves with the same candidate/pick rules as a
read, over the statement's writable relations:
t2.col(qualified)colcolcolcolAmbiguouscolUnresolvedThe mirror applies only where there is a genuine choice (several writable
relations). The sink is text-named — and keeps pinning unconditionally — for a
single-table UPDATE, MERGE SET, ON CONFLICT SET, and PostgreSQL / T-SQL
UPDATE t SET … FROM u(FROM relations join the read scope but are neverwritable; the writable set is snapshotted before FROM absorption).
Implementation reuses the read side's resolution (
resolve_in/pick)restricted to real-table relations, so witness rules and downgrades match
reads exactly. The matrix lives as rustdoc on
resolve_assignment_column.Breaking (
feat!):ColumnWrite's documented contract previously ruledout
Ambiguous/Unresolvedon writes. An unqualified multi-table SET can nowsurface them with
table: None(exactly like an unattributed read), and suchwrites contribute no table-level write —
UPDATE t1 JOIN t2 ON … SET a = 1catalog-free now yields
update_tables: [](was[t1]), with the write stillvisible at column granularity.
Tests: one per matrix row (catalog-aware, in
resolution.rs::catalog_strict),read/write-consistency for
SET a = a + 1, FROM- and MERGE-exclusion, thecatalog-free
Ambiguouspin, and a table-level "no update table" case. Allgates green (fmt / clippy /
test --all/ doc); 758 tests.Edge cases covered on review:
SET a = t2.cstill emits the valueedge — its target carries
table: None+Ambiguous, symmetric with anambiguous source read appearing in lineage (pinned by test).
SET (a, b) = (1, 2)) flows through the same attribution.UPDATE t1, t2 SET …) doesn't parse in sqlparser —unreachable.
ARCHITECTURE.md's reads/sink section now covers the unqualified rule(the matrix stays on
resolve_assignment_column).Deferred (tracked): extending the same catalog-owner machinery to the Oracle
join-view INSERT projection (needs an Insert-model extension for the
companion-table scans).
🤖 Generated with Claude Code