Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 14 additions & 8 deletions sql-insight/src/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions sql-insight/src/resolver/binder/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Binding> {
let tables: Vec<Relation> = 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
Expand Down
123 changes: 101 additions & 22 deletions sql-insight/src/resolver/binder/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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<Assignment> {
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,
Expand All @@ -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)
}
}
}
Expand All @@ -954,6 +964,7 @@ impl<'a> Binder<'a> {
rhs: &SqlExpr,
scope: &Scope,
root: &TableReference,
writable: &[Relation],
) -> Vec<Assignment> {
let values: Vec<Expr> = match rhs {
// `(a, b) = (e0, e1)` — a row value: each element is one value.
Expand All @@ -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<Ident> = name
.0
Expand All @@ -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
Expand Down
Loading