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
+110
View File
@@ -753,6 +753,51 @@ pub fn candidates_at_cursor_with_in_mode(
);
}
// Source 1.95: in-scope table aliases (issue #31). At a bare
// `sql_expr_ident` slot — one *not* already past a `qualifier .`
// (handled by §10.5 column narrowing) — the partial may be a
// FROM-source the learner is mid-typing as a qualifier
// (`group by o` → `o.<column>`). Offer each binding's *qualifier*:
// its alias if it has one, else the table name (an aliased source
// must be referenced by its alias, not the raw table name). This
// makes aliases Tab-discoverable and — since a non-empty candidate
// set overlapping the partial suppresses the under-cursor error
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> =
if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier =
binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
// `NewName` / `Types` / `Free` sources don't query the
@@ -788,6 +833,10 @@ pub fn candidates_at_cursor_with_in_mode(
})
.filter(|name| matches_prefix(name))
.collect();
// Fold in the in-scope alias qualifiers (Source 1.95). They are
// already prefix-filtered; dedup against any column of the same
// spelling happens via the shared sort/dedup below.
identifiers.extend(alias_candidates);
identifiers.sort();
identifiers.dedup();
// If an identifier shares its name with a keyword candidate
@@ -1930,6 +1979,67 @@ mod tests {
cache
}
fn two_table_alias_cache() -> SchemaCache {
use crate::dsl::types::Type;
let mut cache = schema_with_table("a", &[("id", Type::Int), ("name", Type::Text)]);
cache.tables.push("b".to_string());
cache.columns.push("total".to_string());
cache.table_columns.insert(
"b".to_string(),
vec![
TableColumn::new("id", Type::Int),
TableColumn::new("total", Type::Real),
],
);
cache
}
#[test]
fn bare_expr_slot_offers_in_scope_aliases() {
// Issue #31: at a bare SQL-expression slot (here GROUP BY) the
// in-scope FROM aliases are Tab-discoverable, so a learner can
// reach `o.<column>` without guessing the alias.
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
}
#[test]
fn bare_expr_slot_narrows_aliases_by_partial_prefix() {
// A partial that prefix-matches several aliases offers each;
// an exact match (`o`) is the learner's whole alias — no
// sibling-alias noise, so the `alias_used_as_column` hint can
// surface instead (issue #31).
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
let cs2 = cands_with(exact, exact.len(), &cache);
assert!(
!cs2.iter().any(|c| c == "ab"),
"an exact-alias partial must not surface sibling aliases; got {cs2:?}",
);
}
#[test]
fn alias_not_offered_after_a_qualifier_dot() {
// Past `o.` the §10.5 column-narrowing owns the slot; aliases
// are not candidates there.
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by o.";
let cs = cands_with(input, input.len(), &cache);
assert!(
!cs.iter().any(|c| c == "o" || c == "z"),
"aliases must not be offered after a qualifier dot; got {cs:?}",
);
}
#[test]
fn update_set_offers_only_current_table_columns() {
use crate::dsl::types::Type;