fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)

A bare table alias typed where a column is expected — `… GROUP BY o`,
with `o` aliasing `FROM Orders o` — was a blind spot: completion offered
nothing for `o`, and the hint panel called the in-scope alias an unknown
column (`no such column o on table Orders, ...`).

Completion now offers each FROM source's qualifier (alias-if-present-else
table-name) at a bare sql_expr_ident slot, folded into the column
candidate list; on an exact-qualifier partial the alias source steps
aside so the diagnostic can surface. The bare-reference diagnostic arm
emits a targeted `alias_used_as_column` / `table_used_as_column` hint
("`o` is a table alias — write `o.<column>` ...") after the
projection-alias check, so ORDER-BY alias refs still win and a genuine
unknown column still reports `unknown_column`.

Two guards keep the qualified-form advice correct: SQL only (role
`sql_expr_ident`, so the DSL `expr_column` path keeps `unknown_column`
since the DSL has no `table.column` syntax) and effective-qualifier
match (alias-if-present-else-table, so an aliased source referenced by
its shadowed real name falls through rather than being advised as
`name.<column>`). The diagnostic is a drop-in replacement for
`unknown_column` at the same span/Error severity, so verdict/overlay/hint
paths are unchanged.

ADR-0032 Amendment 3; +10 tests.
This commit is contained in:
claude@clouddev1
2026-06-12 14:03:00 +00:00
parent 82b9f7f9b9
commit 7e4bc122be
7 changed files with 417 additions and 1 deletions
+169
View File
@@ -1163,6 +1163,60 @@ fn schema_existence_diagnostics(
// Allowed-clause alias ref — silent.
continue;
}
// Issue #31: the bare ident is itself an
// in-scope FROM source — a table alias
// (`o` from `FROM Orders o`) or, when the
// source is un-aliased, the table name. The
// learner means a *column* of that source
// (`o.<column>`); calling it an "unknown
// column" misleads. Point at the qualified
// form.
//
// Two guards keep the advice correct:
// - SQL only (`role == "sql_expr_ident"`):
// the DSL `Expr` (role `expr_column`)
// has no `table.column` syntax, so the
// qualified-form advice would be wrong;
// it keeps the generic unknown_column.
// - Match the *effective qualifier*
// (alias if present, else table name),
// not the table name independently. An
// aliased source must be referenced by
// its alias — `FROM Orders o … Orders`
// is invalid SQL, so it must NOT be
// advised as `Orders.<column>`. Mirrors
// the completion side's qualifier rule.
let qualifier_binding = (role
== "sql_expr_ident")
.then(|| {
bindings.iter().find(|b| {
let q = b
.alias
.as_deref()
.unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&item.text)
})
})
.flatten();
if let Some(binding) = qualifier_binding {
let key = if binding.alias.is_some() {
"diagnostic.alias_used_as_column"
} else {
"diagnostic.table_used_as_column"
};
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: item.span,
message: crate::friendly::translate(
key,
&[(
"name",
&item.text as &dyn std::fmt::Display,
)],
),
});
continue;
}
let table_arg = if bindings.len() == 1 {
bindings[0].table.clone()
} else {
@@ -6330,6 +6384,121 @@ mod tests {
);
}
// ---- Issue #31 — bare table alias / table used as a column ----
#[test]
fn bare_table_alias_in_group_by_is_alias_hint_not_unknown_column() {
// Issue #31: `… GROUP BY o` where `o` aliases a FROM source.
// The learner means `o.<column>`; the diagnostic must point
// at the qualified form, NOT call `o` an unknown column.
let schema = two_table_schema();
let diags = diag_keys(
"select a.id from a o join b on a.id = b.id group by o",
&schema,
);
assert!(
diags
.iter()
.any(|d| d.contains("`o` is a table alias") && d.contains("o.<column>")),
"expected alias_used_as_column hint; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("no such column")),
"unknown_column must not fire for an in-scope alias; got {diags:?}",
);
}
#[test]
fn bare_table_alias_in_projection_is_alias_hint() {
// The same applies outside GROUP BY — a bare alias in the
// projection (`SELECT o …`) is equally not a column.
let schema = two_table_schema();
let diags =
diag_keys("select o from a o join b on a.id = b.id", &schema);
assert!(
diags.iter().any(|d| d.contains("`o` is a table alias")),
"expected alias_used_as_column hint in projection; got {diags:?}",
);
}
#[test]
fn bare_unaliased_table_used_as_column_is_table_hint() {
// An un-aliased FROM source referenced bare gets the
// table-form hint (qualify with the table name).
let schema = two_table_schema();
let diags = diag_keys("select id from a group by a", &schema);
assert!(
diags
.iter()
.any(|d| d.contains("`a` is a table") && d.contains("a.<column>")),
"expected table_used_as_column hint; got {diags:?}",
);
}
#[test]
fn genuine_unknown_column_still_reports_no_such_column() {
// Regression guard: the alias branch must not swallow a
// genuine typo. `nope` matches no alias, no table, no column.
let schema = two_table_schema();
let diags = diag_keys(
"select a.id from a o join b on a.id = b.id group by nope",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column") && d.contains("nope")),
"a genuine unknown column must still report no such column; got {diags:?}",
);
}
#[test]
fn aliased_table_referenced_by_real_name_is_not_table_hint() {
// DA guard (issue #31): a source aliased as `x` must be
// referenced by the alias — `FROM a x … GROUP BY a` is invalid
// SQL, so we must NOT advise `a.<column>`. The branch matches
// the *effective qualifier* (the alias when present), so `a`
// (the now-shadowed table name) falls through to the generic
// unknown_column rather than wrong qualified-form advice.
let schema = two_table_schema();
let diags = diag_keys(
"select x.id from a x join b on x.id = b.id group by a",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column") && d.contains("`a`")),
"an aliased table referenced by its real name must fall through to \
unknown_column; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("is a table")),
"must not advise `a.<column>` when `a` is aliased as `x`; got {diags:?}",
);
}
#[test]
fn dsl_bare_table_name_in_where_keeps_unknown_column() {
// DA guard (issue #31): the alias/table hint is SQL-only
// (role `sql_expr_ident`). The DSL `Expr` (role `expr_column`)
// has no `table.column` syntax, so advising the qualified form
// would be wrong. A DSL bare table-name ref stays the generic
// unknown_column it was before issue #31.
let schema =
schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
for input in [
"show data Customers where Customers = 5",
"update Customers set Name = 'x' where Customers = 5",
] {
let diags = diag_keys_simple(input, &schema);
assert!(
diags.iter().any(|d| d.contains("no such column")),
"DSL bare table ref must stay unknown_column for {input:?}; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("is a table")),
"DSL must not get SQL qualified-form advice for {input:?}; got {diags:?}",
);
}
}
// ---- ADR-0032 §11.2 — compound_arity_mismatch ----
#[test]