From a53b8c7275fac5b64acda9f9bc66f00a81f0df74 Mon Sep 17 00:00:00 2001 From: Takahiro Ebato Date: Sat, 4 Jul 2026 22:44:49 +0900 Subject: [PATCH] feat!: attribute unqualified SET targets with the read-side rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 7 +- sql-insight/src/reference.rs | 22 ++- sql-insight/src/resolver/binder/resolve.rs | 31 ++++ sql-insight/src/resolver/binder/statement.rs | 123 ++++++++++--- .../column_operation_extractor/resolution.rs | 168 ++++++++++++++++++ .../writes_deletes.rs | 59 ++++++ sql-insight/tests/crud_table_extractor.rs | 19 ++ 7 files changed, 398 insertions(+), 31 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da81b27..212c782 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,7 +92,12 @@ source (`INSERT INTO t SELECT * FROM t`) reads through that scan. `collect_table_reads` = every `Scan` ∪ any referenced relation not already scanned (the sink). A multi-table `UPDATE t1 JOIN t2 SET t2.col = …` writes (and lineage-targets) the relation each SET qualifier resolves to, carried on -`Assignment.target`, not the root. +`Assignment.target`, not the root; an *unqualified* SET target among several +writable relations is attributed with the read side's candidate rules (a sole +catalog owner pins; otherwise the write surfaces unattributed — `table: None`, +`Ambiguous` / `Unresolved` — and contributes no table-level write, matching +MySQL's own ambiguity error). The full matrix lives on +`resolve_assignment_column`. ### Value vs filter is structural diff --git a/sql-insight/src/reference.rs b/sql-insight/src/reference.rs index ed8dce2..d6852f5 100644 --- a/sql-insight/src/reference.rs +++ b/sql-insight/src/reference.rs @@ -150,15 +150,21 @@ pub struct ColumnRead { /// ([`ResolutionKind`]). The write-role counterpart of [`ColumnRead`], kept a /// distinct type so a read can't be passed where a write is meant. /// -/// `resolution` is the column's catalog match against its (always pinned) write -/// target: [`Cataloged`](ResolutionKind::Cataloged) when the column is in the -/// target's catalog column list, else [`Inferred`](ResolutionKind::Inferred) +/// `resolution` is the column's catalog match against its write target: +/// [`Cataloged`](ResolutionKind::Cataloged) when the column is in the target's +/// catalog column list, else [`Inferred`](ResolutionKind::Inferred) /// (catalog-free, the target's columns aren't known, the column isn't listed, -/// or a freshly created / altered relation). A written column's owning table is -/// always pinned and the column is named, so -/// [`Unresolved`](ResolutionKind::Unresolved) / -/// [`Ambiguous`](ResolutionKind::Ambiguous) never arise — mirroring how a base -/// column read resolves against its relation's column list. +/// or a freshly created / altered relation). +/// +/// The owning table is pinned whenever the statement names the sink — every +/// INSERT / DDL write, a qualified `SET t2.col`, and an unqualified SET with +/// one writable relation. Only an **unqualified SET among several writable +/// relations** (a multi-table `UPDATE t1 JOIN t2 SET col = …`) is *inferred*, +/// with the same rules as a read: a sole candidate pins its owner, several +/// candidates surface [`Ambiguous`](ResolutionKind::Ambiguous) and none +/// [`Unresolved`](ResolutionKind::Unresolved) — `table: None`, the column +/// still named, exactly like an unattributed [`ColumnRead`]. An unattributed +/// write contributes no table-level write. /// /// [`ColumnOperation::writes`]: crate::extractor::ColumnOperation::writes /// [`ColumnTarget::Relation`]: crate::extractor::ColumnTarget::Relation diff --git a/sql-insight/src/resolver/binder/resolve.rs b/sql-insight/src/resolver/binder/resolve.rs index 781d598..e2c12ae 100644 --- a/sql-insight/src/resolver/binder/resolve.rs +++ b/sql-insight/src/resolver/binder/resolve.rs @@ -121,6 +121,37 @@ impl<'a> Binder<'a> { } } + /// Resolve an unqualified *written* column (a `SET col = …` target) against + /// the statement's `writable` relations, with the **same candidate / pick + /// rules as a read** ([`resolve_in`](Self::resolve_in)) — but only when + /// there is a genuine choice. With zero or one writable relation the sink + /// is named by the statement itself (a single-table UPDATE / MERGE / + /// conflict SET), not inferred — `None`, and the caller pins the DML root + /// unconditionally. With several, the read-mirrored outcome comes back + /// verbatim: the sole candidate (witness downgrades and all) as + /// [`Binding::Base`], several candidates as [`Binding::Ambiguous`], none as + /// [`Binding::Unresolved`]. Only real tables participate — a derived / + /// table-function relation can't be written. + pub(super) fn unqualified_write_binding( + &self, + column: &Ident, + writable: &[Relation], + ) -> Option { + let tables: Vec = writable + .iter() + .filter(|rel| matches!(rel, Relation::Table { .. })) + .cloned() + .collect(); + if tables.len() < 2 { + return None; + } + let parts = [column.clone()]; + Some( + self.resolve_in(&parts, &tables) + .unwrap_or(Binding::Unresolved), + ) + } + /// A relation is a qualified candidate iff the qualifier matches it: a /// non-aliased real table by right-anchored path, anything else by its /// single exposed (alias) name. A `Cataloged` table that doesn't list the diff --git a/sql-insight/src/resolver/binder/statement.rs b/sql-insight/src/resolver/binder/statement.rs index 6271d4b..15ce352 100644 --- a/sql-insight/src/resolver/binder/statement.rs +++ b/sql-insight/src/resolver/binder/statement.rs @@ -378,10 +378,11 @@ impl<'a> Binder<'a> { // `OnInsert` is non-exhaustive; an unmodelled action is a no-op. _ => return (Vec::new(), Vec::new()), }; - // A conflict-action SET always targets the insert target's own columns. + // A conflict-action SET always targets the insert target's own columns + // (no other writable relations, hence the empty candidate set). let bound = assignments .iter() - .flat_map(|a| self.bind_assignment(a, &scope, target)) + .flat_map(|a| self.bind_assignment(a, &scope, target, &[])) .collect(); let predicate = selection .map(|s| self.bind_expr(s, &scope)) @@ -446,6 +447,11 @@ impl<'a> Binder<'a> { .collect(); input = join(input, node, on); } + // The unqualified-SET attribution candidates: the target plus its + // clause joins (MySQL's writable set) — snapshotted *before* the FROM + // relations join the scope, which are readable but never writable + // (PostgreSQL / T-SQL `UPDATE t SET … FROM u` only ever writes `t`). + let writable = scope.relations.clone(); // FROM relations are reads (resolved against the target + joins so far). if let Some(from) = &update.from { let tables = match from { @@ -474,13 +480,13 @@ impl<'a> Binder<'a> { }); } // SET assignments resolve against the target + FROM scope; each writes - // its resolved target table (the root, or the relation a qualifier names - // in a multi-table `UPDATE t1 JOIN t2 SET t2.col = …`). A tuple - // `SET (a, b) = …` expands to one assignment per target column. + // its resolved target table (see `resolve_assignment_column` for the + // attribution rules). A tuple `SET (a, b) = …` expands to one + // assignment per target column. let assignments = update .assignments .iter() - .flat_map(|a| self.bind_assignment(a, &scope, &target)) + .flat_map(|a| self.bind_assignment(a, &scope, &target, &writable)) .collect(); // RETURNING resolves against the statement scope (target + FROM). let returning = self.bind_returning(&update.returning, &scope); @@ -693,11 +699,12 @@ impl<'a> Binder<'a> { on.push(self.bind_expr(predicate, &scope)); } // A MERGE WHEN UPDATE always targets the merge target's - // own columns (a tuple SET expands per target column). + // own columns (a tuple SET expands per target column; the + // source is read-only, hence the empty candidate set). let assignments = update .assignments .iter() - .flat_map(|a| self.bind_assignment(a, &scope, &target)) + .flat_map(|a| self.bind_assignment(a, &scope, &target, &[])) .collect(); clauses.push(MergeClause::Update { assignments }); } @@ -913,18 +920,21 @@ impl<'a> Binder<'a> { /// Bind one SET assignment into the per-column [`Assignment`]s it writes — /// one for a single `col = expr`, several for a tuple `(a, b) = …` (one per /// target column). See [`bind_tuple_assignment`](Self::bind_tuple_assignment) - /// for the tuple pairing. `root` is the DML target an unqualified column - /// writes; `scope` resolves a qualified `t2.col` and the RHS reads. + /// for the tuple pairing. `scope` resolves a qualified `t2.col` and the RHS + /// reads; `root` / `writable` drive the unqualified write attribution — see + /// [`resolve_assignment_column`](Self::resolve_assignment_column) for the + /// rules. pub(super) fn bind_assignment( &mut self, assignment: &SqlAssignment, scope: &Scope, root: &TableReference, + writable: &[Relation], ) -> Vec { match &assignment.target { AssignmentTarget::ColumnName(name) => { let value = self.bind_expr(&assignment.value, scope); - self.resolve_assignment_column(name, scope, root) + self.resolve_assignment_column(name, scope, root, writable) .map(|(target, target_resolution)| Assignment { target, target_resolution, @@ -934,7 +944,7 @@ impl<'a> Binder<'a> { .collect() } AssignmentTarget::Tuple(names) => { - self.bind_tuple_assignment(names, &assignment.value, scope, root) + self.bind_tuple_assignment(names, &assignment.value, scope, root, writable) } } } @@ -954,6 +964,7 @@ impl<'a> Binder<'a> { rhs: &SqlExpr, scope: &Scope, root: &TableReference, + writable: &[Relation], ) -> Vec { let values: Vec = match rhs { // `(a, b) = (e0, e1)` — a row value: each element is one value. @@ -977,28 +988,61 @@ impl<'a> Binder<'a> { .iter() .zip(values) .filter_map(|(name, value)| { - self.resolve_assignment_column(name, scope, root).map( - |(target, target_resolution)| Assignment { + self.resolve_assignment_column(name, scope, root, writable) + .map(|(target, target_resolution)| Assignment { target, target_resolution, value, - }, - ) + }) }) .collect() } /// Resolve a SET assignment's target column to the column it writes, - /// qualified by its **resolved table**: an unqualified column writes the DML - /// `root`; a qualified `t2.col` (a multi-table `UPDATE t1 JOIN t2 SET t2.col - /// = …`) writes whichever in-scope real table the qualifier names. Returns - /// `None` — dropped — for a qualifier that names no writable table (a - /// derived table / CTE / unknown alias can't be a write target). + /// qualified by its **resolved table**. Running example — a multi-table + /// MySQL UPDATE, whose target clause makes both tables writable: + /// + /// `UPDATE t1 JOIN t2 ON t1.id = t2.id SET t2.col = 1, other = 2` + /// + /// The **qualified** target (`t2.col`) writes whichever in-scope real + /// table its qualifier names — `t2` here; a qualifier naming no writable + /// table (a derived table / CTE / unknown alias) drops the assignment + /// (`None`). The **unqualified** target (`other`) is attributed with the + /// read side's candidate / pick rules over the `writable` relations + /// (`t1`, `t2`) — but only when there are several, as here (a genuine + /// inference); with zero or one the sink is named by the statement + /// itself, so the DML `root` pins unconditionally. `writable` is the + /// target-clause relations for a multi-table UPDATE (MySQL's writable + /// set — snapshotted before `FROM` joins the scope, since PostgreSQL / + /// T-SQL `UPDATE t SET … FROM u` never writes `u`), and **empty** for a + /// conflict / MERGE SET (always the statement's own target). + /// + /// The full attribution matrix, `t2.col` / `other` as in the example + /// (`✔` = the column-level catalog match: `Cataloged` iff the pinned + /// table lists the column, else `Inferred`): + /// + /// | SET target | writable relations | written table | resolution | + /// |------------|-------------------------------|---------------------|-------------------| + /// | `t2.col` | (any) | `t2` (the qualifier's) | ✔ | + /// | `other` | none besides root (MERGE / ON CONFLICT) | root | ✔ | + /// | `other` | one (single-table UPDATE) | root | ✔ | + /// | `other` | several — sole candidate | the owner | read-mirrored (`Cataloged` verbatim; witness over catalog-free suspects downgrades to `Inferred`) | + /// | `other` | several — several candidates | *none* | `Ambiguous` | + /// | `other` | several — no candidate | *none* | `Unresolved` | + /// + /// The read mirror keeps `SET a = a + 1` coherent: the write pins exactly + /// the table the RHS read resolves to (or honestly neither, as `Ambiguous` + /// / `Unresolved` with `table: None` — the write then contributes no + /// table-level write, like an unattributed read contributes no scan). + /// Real MySQL agrees with the unattributed rows: an unqualified column + /// owned by several joined tables is an error (1052), so no write target + /// ever exists to pin. fn resolve_assignment_column( &self, name: &ObjectName, scope: &Scope, root: &TableReference, + writable: &[Relation], ) -> Option<(ColumnWrite, ResolutionKind)> { let parts: Vec = name .0 @@ -1007,7 +1051,42 @@ impl<'a> Binder<'a> { .collect(); let column = parts.last()?.clone(); let table = if parts.len() == 1 { - root.clone() // unqualified → the DML root target + match self.unqualified_write_binding(&column, writable) { + // A genuine multi-relation inference: mirror the read outcome. + Some(Binding::Base { table, resolution }) => { + let table_resolution = self.table_match(&table).resolution; + return Some(( + ColumnWrite { + reference: crate::reference::ColumnReference { + table: Some(table), + name: column, + }, + resolution, + }, + table_resolution, + )); + } + Some(kind @ (Binding::Ambiguous | Binding::Unresolved)) => { + let resolution = match kind { + Binding::Ambiguous => ResolutionKind::Ambiguous, + _ => ResolutionKind::Unresolved, + }; + return Some(( + ColumnWrite { + reference: crate::reference::ColumnReference { + table: None, + name: column, + }, + resolution, + }, + resolution, + )); + } + // `Derived` / `Local` can't arise (only real tables are + // candidates); a `writable` of zero / one relation means the + // statement names the sink — the root. + Some(Binding::Derived | Binding::Local) | None => root.clone(), + } } else { let qualifier = &parts[..parts.len() - 1]; scope diff --git a/sql-insight/tests/column_operation_extractor/resolution.rs b/sql-insight/tests/column_operation_extractor/resolution.rs index c8e51d4..4fb0c9c 100644 --- a/sql-insight/tests/column_operation_extractor/resolution.rs +++ b/sql-insight/tests/column_operation_extractor/resolution.rs @@ -496,6 +496,174 @@ mod catalog_strict { }, ); } + + // ===== unqualified SET write attribution ============================= + // + // An unqualified SET target in a *multi-table* UPDATE is attributed with + // the read side's candidate rules over the writable relations (see + // `resolve_assignment_column`'s attribution matrix). These pin one test + // per matrix row. Real MySQL agrees: an ambiguous unqualified column in a + // multi-table UPDATE is an error (1052), so no side is fabricated. + + /// A written column the attribution couldn't pin — surfaced with + /// `table: None`, exactly like an unattributed read. + fn write_unattributed(col: &str, resolution: ResolutionKind) -> ColumnWrite { + ColumnWrite { + reference: ColumnReference { + table: None, + name: col.into(), + }, + resolution, + } + } + + #[test] + fn unqualified_set_attributes_to_the_sole_catalog_owner() { + // Only t2 lists `a` — the write pins t2 (previously the root t1). + let catalog = TestCatalog::default() + .with("t1", vec!["id", "x"]) + .with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read_confirmed("t1", "id"), read_confirmed("t2", "id")], + writes: vec![write("t2", "a")], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn unqualified_set_witness_over_suspect_downgrades() { + // t2 (registered, lists `a`) is the sole witness; the unregistered t1 + // is an Unknown suspect — the witness wins but downgrades to + // `Inferred`, exactly like a read. + let catalog = TestCatalog::default().with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read("t1", "id"), read_confirmed("t2", "id")], + writes: vec![write_inferred("t2", "a")], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn unqualified_set_with_several_owners_is_ambiguous() { + // Both tables list `a`: real MySQL rejects the statement (error 1052), + // so no write target exists to pin — the write surfaces unattributed + // (`table: None`, `Ambiguous`) and contributes no table-level write. + let catalog = TestCatalog::default() + .with("t1", vec!["id", "a"]) + .with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read_confirmed("t1", "id"), read_confirmed("t2", "id")], + writes: vec![write_unattributed("a", ResolutionKind::Ambiguous)], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn unqualified_set_with_no_owner_is_unresolved() { + // Both tables are registered and neither lists `a` — no candidate + // owner, mirroring the read side's `Unresolved`. + let catalog = TestCatalog::default() + .with("t1", vec!["id"]) + .with("t2", vec!["id"]); + assert_column_ops_with_catalog( + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read_confirmed("t1", "id"), read_confirmed("t2", "id")], + writes: vec![write_unattributed("a", ResolutionKind::Unresolved)], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn self_referencing_unqualified_set_reads_and_writes_the_same_table() { + // The point of mirroring the read rules: in `SET a = a + 1` the RHS + // read and the write target resolve to the same table (t2, the sole + // owner) — no more "read t2.a, write t1.a" self-contradiction. + let catalog = TestCatalog::default() + .with("t1", vec!["id", "x"]) + .with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = a + 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![ + read_confirmed("t1", "id"), + read_confirmed("t2", "id"), + read_confirmed("t2", "a"), + ], + writes: vec![write("t2", "a")], + lineage: vec![transformation( + col_confirmed("t2", "a"), + ColumnTarget::Relation(write("t2", "a")), + )], + diagnostics: vec![], + }, + ); + } + + #[test] + fn from_relations_are_not_attribution_candidates() { + // PostgreSQL / T-SQL `UPDATE t SET … FROM u` never writes `u`: the + // FROM relations join the scope for reads, but the writable set stays + // the target alone — so `a` pins t1 even though only t2 lists it. + let catalog = TestCatalog::default() + .with("t1", vec!["id", "x"]) + .with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "UPDATE t1 SET a = 1 FROM t2", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![], + writes: vec![write_inferred("t1", "a")], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn merge_set_always_targets_the_merge_target() { + // A MERGE source is read-only — the unqualified SET pins the merge + // target even when only the source lists the column. + let catalog = TestCatalog::default() + .with("t1", vec!["id", "x"]) + .with("t2", vec!["id", "a"]); + assert_column_ops_with_catalog( + "MERGE INTO t1 USING t2 ON t1.id = t2.id WHEN MATCHED THEN UPDATE SET a = 1", + &catalog, + ColumnOperation { + statement_kind: StatementKind::Merge, + reads: vec![read_confirmed("t1", "id"), read_confirmed("t2", "id")], + writes: vec![write_inferred("t1", "a")], + lineage: vec![], + diagnostics: vec![], + }, + ); + } } /// Pins one row per case from the [`ResolutionKind`] rustdoc's behavior diff --git a/sql-insight/tests/column_operation_extractor/writes_deletes.rs b/sql-insight/tests/column_operation_extractor/writes_deletes.rs index a905b3a..ef09e0a 100644 --- a/sql-insight/tests/column_operation_extractor/writes_deletes.rs +++ b/sql-insight/tests/column_operation_extractor/writes_deletes.rs @@ -163,6 +163,65 @@ mod writes { }, ); } + + #[test] + fn multi_table_unqualified_set_is_ambiguous_without_a_catalog() { + // Catalog-free, both joined tables are Unknown suspects, so the + // unqualified SET target can't be attributed — real MySQL rejects the + // statement outright (error 1052) when both tables own the column, so + // no side is fabricated. The write surfaces unattributed (`table: + // None`, `Ambiguous`), mirroring the read side, and contributes no + // table-level write. (Previously it silently pinned the root t1.) + use sql_insight::sqlparser::dialect::MySqlDialect; + assert_column_ops_with_dialect( + &MySqlDialect {}, + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1", + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read("t1", "id"), read("t2", "id")], + writes: vec![ColumnWrite { + reference: ColumnReference { + table: None, + name: "a".into(), + }, + resolution: ResolutionKind::Ambiguous, + }], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + + #[test] + fn lineage_still_targets_an_unattributed_set_column() { + // A value RHS still traces to the unattributed write — the edge's + // target carries `table: None` + `Ambiguous`, symmetric with an + // ambiguous *source* read appearing in lineage. The value dependency + // (`t2.c` flows somewhere) is real even when the sink table isn't + // determinable. + use sql_insight::sqlparser::dialect::MySqlDialect; + let unattributed = ColumnWrite { + reference: ColumnReference { + table: None, + name: "a".into(), + }, + resolution: ResolutionKind::Ambiguous, + }; + assert_column_ops_with_dialect( + &MySqlDialect {}, + "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = t2.c", + ColumnOperation { + statement_kind: StatementKind::Update, + reads: vec![read("t1", "id"), read("t2", "id"), read("t2", "c")], + writes: vec![unattributed.clone()], + lineage: vec![passthrough( + col("t2", "c"), + ColumnTarget::Relation(unattributed), + )], + diagnostics: vec![], + }, + ); + } } mod delete { diff --git a/sql-insight/tests/crud_table_extractor.rs b/sql-insight/tests/crud_table_extractor.rs index 62d28c1..50e2e7c 100644 --- a/sql-insight/tests/crud_table_extractor.rs +++ b/sql-insight/tests/crud_table_extractor.rs @@ -107,6 +107,25 @@ mod basic { ); } + #[test] + fn unattributed_multi_table_set_contributes_no_update_table() { + // `UPDATE t1 JOIN t2 SET a = 1` catalog-free: the unqualified SET + // can't be attributed to a single table (MySQL itself rejects the + // statement when both own `a`), so no update target surfaces — the + // joined tables stay reads, and the unattributed write is visible at + // column granularity (`table: None`, `Ambiguous`). + use sql_insight::sqlparser::dialect::MySqlDialect; + let sql = "UPDATE t1 JOIN t2 ON t1.id = t2.id SET a = 1"; + let expected = vec![Ok(CrudTables { + create_tables: vec![], + read_tables: vec![cread(table("t1")), cread(table("t2"))], + update_tables: vec![], + delete_tables: vec![], + diagnostics: vec![], + })]; + assert_crud_table_extraction(sql, expected, vec![Box::new(MySqlDialect {})]); + } + #[test] fn update_array_join_operand_is_not_a_read_table() { // The UPDATE target's join clause takes the same ARRAY JOIN special