Repository navigation
Fully-qualified column references fabricate a relation from the dotted qualifier #29
Description
Found while reviewing #28, which fixes the same defect class for Expr::CompoundFieldAccess. This is Expr::CompoundIdentifier — the same names written without a subscript — and it is a fourth fabrication site of the kind #23 was about.
The same column, spelled two ways, gives two different answers
SELECT tbl.col FROM sch.tbl -- Concrete { schema: "sch", table: "tbl", column: "col" }
SELECT sch.tbl.col FROM sch.tbl -- Concrete { schema: null, table: "sch.tbl", column: "col" }The second is wrong in a way that is easy to miss: the dotted name is crammed into the table field of a TableRef whose schema stays null. That TableRef is not equal to the one in tables.inputs, which is correctly { schema: "sch", table: "tbl" } — so a consumer joining column lineage to table lineage by TableRef finds no match. This is ordinary schema.table.column SQL, not an exotic shape, and fully qualifying is exactly what people do in a join:
SELECT sch.tbl.a FROM sch.tbl JOIN sch.other ON sch.tbl.id = sch.other.id
inputs: sch.other, sch.tbl
a -> Concrete { table: "sch.tbl", column: "a" }Cause
The binding is registered under the last name component and looked up by the whole dotted string.
build/select.rs keys the binding by table_ref.table:
let alias_name = alias
.as_ref()
.map_or_else(|| table_ref.table.clone(), |a| a.name.value.clone());
self.add_binding(alias_name, Binding::Table(table_ref));build/select.rs::split_compound builds the qualifier by joining every component but the last:
let qualifier = parts[..len - 1].iter().map(|p| p.value.as_str())
.collect::<Vec<_>>().join(".");So FROM sch.tbl registers "tbl", SELECT sch.tbl.col asks for "sch.tbl", the lookup misses, and resolve/mod.rs invents a relation from the string it failed to resolve:
None => Some(ColumnOrigin::Concrete {
table: TableRef::new(qual.as_str()),
column: name.clone(),
}),They can only agree when the reference carries exactly one qualifier component. SELECT sch.tbl.col FROM sch.tbl AS tbl misses too — the alias is tbl, the qualifier is still sch.tbl.
Three symptoms, one arm
That None arm is reached three different ways, and Concrete is wrong in all three:
| now | should be | |
|---|---|---|
SELECT sch.tbl.col FROM sch.tbl |
Concrete { table: "sch.tbl" } |
Concrete { schema: "sch", table: "tbl", column: "col" } |
SELECT t.payload.items FROM t |
Concrete { table: "t.payload" } |
Concrete { table: "t", column: "payload" } — payload is the physical column, items a struct field |
SELECT zz.col FROM t |
Concrete { table: "zz" } |
Unresolved { column: "col" } — nothing in scope is named zz |
The middle row is what #28 fixes for the subscripted spelling (t.payload.items[1]), so after #28 those two spellings of the same expression disagree.
The last row is the plain #23 case. #23 routed three fabrication sites to Unresolved and left this one, because it only reached the unqualified paths.
What a fix needs
The qualifier has to be matched against bindings as a relation name of one or more components rather than as a single string, longest prefix first, before falling back:
- try the longest leading run of components that names a binding — that is the relation, and the next component is the column;
- otherwise, if the first component alone names a binding, the second is the column and the rest is a struct field path (this is what
collect_compound_field_ancestorsalready does in fix: stop naming relations and columns that do not exist #28 for the subscripted form); - otherwise nothing in scope owns the column, so
Unresolved.
a.b.c is genuinely ambiguous between (1) and (2) at the syntax level, and the scope is the only thing that can settle it — the same argument #28's commit makes. Worth doing both spellings in one change so they cannot drift apart again.
Related: #23 (the rule this breaks), #28 (same class, CompoundFieldAccess), #14/#16 (360e1e7 reworks this area and adds bound-field machinery).
Activity