fix: SQL function-call names not flagged as columns

Two layered fixes for the same bug class: `select sum(Age) from
Customers` runs cleanly at the engine but the validator was treating
`sum` as a column reference. The grammar already admits function
calls structurally (ADR-0031 §1: "it does not know which names are
aggregates"); the validator needed to match.

1. Walker (schema_existence_diagnostics): the bare-column check on
   `sql_expr_ident` items now skips when the ident is immediately
   followed by `(` — it's a function-call name, not a column. New
   helper `is_followed_by_call_args` mirrors the existing
   `is_followed_by_qualified_ref` guard. Args inside the call are
   ordinary expressions and their idents still flow through the
   normal bare-column check on subsequent iterations.

   Cascades to: the [ERR] validity indicator (verdict derived from
   diagnostics), the red highlight overlay (renderer overlays
   diagnostic spans), and the ambient hint at complete inputs (the
   diagnostic-driven `pick_hint_diagnostic` path).

2. Typing-time (invalid_ident_at_cursor_in_mode): at any
   `sql_expr_ident` position the partial could resolve to either a
   column reference or a function-call name; without lookahead for a
   trailing `(` we can't tell. The check now returns early at
   `sql_expr_ident` positions. Submit-time still catches genuine
   column typos: the schema-existence diagnostic only skips when the
   ident *is* followed by `(`, so a bare unknown ident still trips
   and the hint surfaces it via `pick_hint_diagnostic`.

Trade-off worth flagging: typing `select Agx` (no FROM yet) is now
silent until FROM is added; previously the typing-time path flagged
it as "No such column". This makes typing-time consistent with
submit-time — the schema-existence pass already silently skips
no-FROM expressions ("no FROM in scope — engine catches"). For any
expression-with-scope (SELECT with FROM, WHERE, etc.) the
diagnostic-driven hint still fires for column typos; new test pins
this.

Tests added (5): walker positive (every standard aggregate plus
count(*), count(distinct …), nested calls, WHERE-clause functions,
and non-aggregate functions), walker negative (unknown column inside
call args), walker negative for DISTINCT-shielded arg, typing-time
positive (no false flag on partial function name), typing-time
trade-off lockdown (genuine column typo still hints when FROM is in
scope).

No grammar change; no ADR amendment (the fix matches ADR-0031 §1's
existing posture). Full suite 2040 passed / 0 failed / 0 unexpected
skips. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-28 22:03:31 +00:00
parent 6f87ad1842
commit 24c268541e
3 changed files with 206 additions and 0 deletions
+81
View File
@@ -1169,6 +1169,87 @@ mod tests {
}
}
#[test]
fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
// Issue #6 trade-off lockdown: dropping the typing-time
// `invalid_ident_at_cursor` flag at `sql_expr_ident` positions
// (to avoid the false positive on function names like `sum`)
// must not silently kill the typo signal for *genuine* column
// typos. Once the SELECT is structurally complete (FROM is in
// scope), the schema-existence pass fires `unknown_column`
// and the ambient hint surfaces that diagnostic via
// `pick_hint_diagnostic`. The user still gets the typing-time
// warning, just through a different path.
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
cache.table_columns.insert("Customers".to_string(), tc);
for input in [
"select Agx from Customers",
"select * from Customers where Agx = 5",
"select * from Customers where Agx",
] {
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("no such column") && p.contains("Agx"),
"complete SELECT with column typo must surface the diagnostic hint for {input:?}; got: {p:?}",
),
other => panic!(
"complete SELECT with column typo must produce a Prose hint for {input:?}; got: {other:?}",
),
}
}
}
#[test]
fn advanced_select_partial_function_name_not_flagged_as_invalid_column() {
// Issue #6 follow-on: while the user is typing
// `select sum` (no `(` yet), the ambient hint must not
// pre-emptively show "No such column: `sum`". At a SQL
// expression position the partial could resolve to either a
// column reference *or* a function-call name; the typing-time
// `invalid_ident_at_cursor` check would otherwise mislead
// because it only knows about schema columns. Submit-time
// validation still flags a genuine column typo (the
// `unknown_column` diagnostic only skips function-call names,
// which require a trailing `(` — so a bare unknown ident
// still trips at submit).
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![
TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
for c in &tc {
cache.columns.push(c.name.clone());
}
cache.table_columns.insert("Customers".to_string(), tc);
let input = "select sum";
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
if let Some(AmbientHint::Prose(p)) = &hint {
assert!(
!p.contains("No such column"),
"`select sum` mid-typing must not pre-emptively flag `sum` as an invalid column; got: {p:?}",
);
}
}
#[test]
fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
// Issue #5 (prose half): at `create table Orders (count` (no