Completion: narrow column candidates to the active table
Two related fixes:
1. \`update MyTable set \` was offering columns from every
table in the project — completion fetched
\`cache.for_source(IdentSource::Columns)\` which returns the
flat \`cache.columns\` (union of every table's columns).
The walker's WalkContext had \`current_table_columns\`
populated (because the update-table-name slot is
\`writes_table: true\`) but the completion engine never
consulted it.
2. \`insert into MyTable (\` was offering nothing — the
value-literal suppression fired because the expected set at
this position contains both Form A column-list candidates
(\`Ident{Columns}\`) and Form C bare-value-list literals
(null/true/false/NumberLit/StringLit). \`is_value_literal_signature\`
matched and the engine returned \`None\` before the column
candidates were considered.
The fix threads the walker's \`current_table_columns\` through
to the completion engine and narrows the suppression rule:
**Walker:**
- New \`walker::CompletionProbe { expected, current_table_columns }\`
struct.
- New \`walker::completion_probe(source, schema) -> CompletionProbe\`
runs one schema-aware walk and reports both the expected
set (or tail_expected on Match) and the resolved table-column
snapshot.
**Completion engine:**
- \`candidates_at_cursor_with\` calls \`completion_probe\` and
reads \`current_table_columns\` for the \`Columns\` ident
source. Schemaless or unknown-table falls back to the flat
\`cache.columns\` (preserves pre-fix behavior).
- Value-literal suppression now gated on
\`!has_schema_ident\` — if the expected set also offers a
schema-listable Ident, the user has actionable candidates
beyond the misleading null/true/false trio and we shouldn't
hide them.
Tests:
- \`update_set_offers_only_current_table_columns\` confirms
Customers' columns appear while Orders' columns don't.
- \`update_where_offers_only_current_table_columns\` covers
the where path.
- \`insert_into_open_paren_offers_current_table_columns\` and
\`insert_into_open_paren_does_not_offer_unrelated_columns\`
cover the Form A column-list position.
- \`drop_column_from_offers_only_current_table_columns\`
documents the DDL fallback (drop-column's table-name slot
doesn't currently \`writes_table\` — falls back to the flat
list).
For the user: \`update MyTable set \` now offers only
MyTable's columns. \`insert into MyTable (\` offers all of
MyTable's columns so Form A is fully discoverable.
Tests: 859 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
+161
-3
@@ -222,7 +222,21 @@ pub fn candidates_at_cursor_with(
|
|||||||
// because there `partial_prefix` is empty.
|
// because there `partial_prefix` is empty.
|
||||||
let input_parses_complete = parse_command(input).is_ok();
|
let input_parses_complete = parse_command(input).is_ok();
|
||||||
|
|
||||||
let expected = expected_at(leading);
|
// Schema-aware probe: one walk yields both the expected set
|
||||||
|
// and the table-context snapshot (ADR-0024 §Phase D
|
||||||
|
// §column-narrowing). The engine reads
|
||||||
|
// `current_table_columns` to narrow column candidates to the
|
||||||
|
// active table rather than the flat `cache.columns` (which
|
||||||
|
// unions every table's columns).
|
||||||
|
let probe = crate::dsl::walker::completion_probe(leading, cache);
|
||||||
|
let current_table_columns: Option<&[TableColumn]> =
|
||||||
|
probe.current_table_columns.as_deref();
|
||||||
|
|
||||||
|
let expected = if probe.expected.is_empty() {
|
||||||
|
expected_at(leading)
|
||||||
|
} else {
|
||||||
|
probe.expected.clone()
|
||||||
|
};
|
||||||
if expected.is_empty() {
|
if expected.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -235,7 +249,23 @@ pub fn candidates_at_cursor_with(
|
|||||||
// number, a quoted string, or a date. Suppress so the
|
// number, a quoted string, or a date. Suppress so the
|
||||||
// ambient_hint ladder falls through to a prose hint with
|
// ambient_hint ladder falls through to a prose hint with
|
||||||
// format examples instead.
|
// format examples instead.
|
||||||
if partial_prefix.is_empty() && is_value_literal_signature(&expected) {
|
//
|
||||||
|
// EXCEPT when the same expected set also contains a
|
||||||
|
// schema-listable Ident expectation — that's the ambiguous
|
||||||
|
// position at `insert into T (` where Form A (column list)
|
||||||
|
// and Form C (bare value list) both apply. There the user
|
||||||
|
// has actionable column candidates that shouldn't be
|
||||||
|
// hidden by the value-literal suppression.
|
||||||
|
let has_schema_ident = expected.iter().any(|e| {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
Expectation::Ident { source, .. } if source.completes_from_schema()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if partial_prefix.is_empty()
|
||||||
|
&& is_value_literal_signature(&expected)
|
||||||
|
&& !has_schema_ident
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +384,12 @@ pub fn candidates_at_cursor_with(
|
|||||||
// matching schema-listable `Ident { source }` expectation.
|
// matching schema-listable `Ident { source }` expectation.
|
||||||
// `NewName` / `Types` / `Free` sources don't query the
|
// `NewName` / `Types` / `Free` sources don't query the
|
||||||
// schema cache and contribute nothing here.
|
// schema cache and contribute nothing here.
|
||||||
|
//
|
||||||
|
// Column candidates narrow to `current_table_columns` when
|
||||||
|
// the walker resolved the active table — `update T set ` at
|
||||||
|
// table T offers T's columns, not every table's columns
|
||||||
|
// (ADR-0024 §Phase D §column-narrowing). Schemaless / table
|
||||||
|
// not in schema falls back to the global `cache.columns`.
|
||||||
let mut identifiers: Vec<String> = expected
|
let mut identifiers: Vec<String> = expected
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
@@ -362,7 +398,16 @@ pub fn candidates_at_cursor_with(
|
|||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.flat_map(|source| cache.for_source(source).iter().cloned())
|
.flat_map(|source| {
|
||||||
|
if source == IdentSource::Columns {
|
||||||
|
current_table_columns.map_or_else(
|
||||||
|
|| cache.for_source(source).to_vec(),
|
||||||
|
|cols| cols.iter().map(|c| c.name.clone()).collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cache.for_source(source).to_vec()
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(|name| matches_prefix(name))
|
.filter(|name| matches_prefix(name))
|
||||||
.collect();
|
.collect();
|
||||||
identifiers.sort();
|
identifiers.sort();
|
||||||
@@ -1038,6 +1083,119 @@ mod tests {
|
|||||||
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn schema_with_table(
|
||||||
|
table: &str,
|
||||||
|
columns: &[(&str, crate::dsl::types::Type)],
|
||||||
|
) -> SchemaCache {
|
||||||
|
let mut cache = SchemaCache::default();
|
||||||
|
cache.tables.push(table.to_string());
|
||||||
|
let cols: Vec<TableColumn> = columns
|
||||||
|
.iter()
|
||||||
|
.map(|(n, t)| TableColumn {
|
||||||
|
name: (*n).to_string(),
|
||||||
|
user_type: *t,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for c in &cols {
|
||||||
|
cache.columns.push(c.name.clone());
|
||||||
|
}
|
||||||
|
cache.table_columns.insert(table.to_string(), cols);
|
||||||
|
cache
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_set_offers_only_current_table_columns() {
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
// SchemaCache.columns has columns from many tables, but
|
||||||
|
// at `update Customers set ` only Customers' columns
|
||||||
|
// should appear.
|
||||||
|
let mut cache = schema_with_table(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Int), ("Email", Type::Text)],
|
||||||
|
);
|
||||||
|
// Pretend the global flat list has columns from a second
|
||||||
|
// table that aren't in Customers.
|
||||||
|
cache.columns.push("OrderTotal".to_string());
|
||||||
|
cache.columns.push("Stock".to_string());
|
||||||
|
cache
|
||||||
|
.table_columns
|
||||||
|
.insert("Orders".to_string(), vec![
|
||||||
|
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real },
|
||||||
|
]);
|
||||||
|
cache.tables.push("Orders".to_string());
|
||||||
|
let cs = cands_with("update Customers set ", 21, &cache);
|
||||||
|
// Customers's columns should appear:
|
||||||
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||||
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
|
// Other tables' columns should NOT:
|
||||||
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||||
|
assert!(!cs.contains(&"Stock".to_string()), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_where_offers_only_current_table_columns() {
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
let mut cache = schema_with_table(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Int), ("Email", Type::Text)],
|
||||||
|
);
|
||||||
|
cache.columns.push("OrderTotal".to_string());
|
||||||
|
let cs =
|
||||||
|
cands_with("update Customers set Email='x' where ", 37, &cache);
|
||||||
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||||
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_into_open_paren_offers_current_table_columns() {
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
let cache = schema_with_table(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
|
||||||
|
);
|
||||||
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||||
|
// The user is at Form A's column-list position. All
|
||||||
|
// three columns of Customers should appear so they can
|
||||||
|
// pick.
|
||||||
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||||
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
|
assert!(cs.contains(&"Name".to_string()), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
let mut cache = schema_with_table(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Int), ("Email", Type::Text)],
|
||||||
|
);
|
||||||
|
cache.columns.push("OrderTotal".to_string());
|
||||||
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||||
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_column_from_offers_only_current_table_columns() {
|
||||||
|
// The drop-column path also writes_table → narrowed
|
||||||
|
// columns should appear here too.
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
let mut cache = schema_with_table(
|
||||||
|
"Customers",
|
||||||
|
&[("id", Type::Int), ("Email", Type::Text)],
|
||||||
|
);
|
||||||
|
cache.columns.push("OrderTotal".to_string());
|
||||||
|
let cs =
|
||||||
|
cands_with("drop column from Customers: ", 28, &cache);
|
||||||
|
// Note: drop column's table-name slot doesn't set
|
||||||
|
// writes_table today (DDL paths don't carry Phase D
|
||||||
|
// table-column resolution yet). Falls back to global
|
||||||
|
// cache.columns, which is the documented schemaless
|
||||||
|
// fallback. Either narrowed-or-flat is acceptable; the
|
||||||
|
// test just confirms valid columns appear.
|
||||||
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_paren_candidate_is_classified_as_punct_kind() {
|
fn open_paren_candidate_is_classified_as_punct_kind() {
|
||||||
// The `(` candidate gets its own kind so the hint
|
// The `(` candidate gets its own kind so the hint
|
||||||
|
|||||||
@@ -222,6 +222,63 @@ const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Completion-engine probe (ADR-0024 §Phase D §column-narrowing).
|
||||||
|
///
|
||||||
|
/// Runs a single schema-aware walk and returns the structured
|
||||||
|
/// pieces the completion engine needs: the expected set plus
|
||||||
|
/// the table-context snapshot the engine reads to narrow
|
||||||
|
/// column candidates to the active table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompletionProbe {
|
||||||
|
pub expected: Vec<outcome::Expectation>,
|
||||||
|
/// Columns of `current_table` resolved at the cursor (set
|
||||||
|
/// by an `Ident { source: Tables, writes_table: true }`
|
||||||
|
/// earlier in the walk). `None` when the walker is
|
||||||
|
/// schemaless or the table didn't resolve.
|
||||||
|
pub current_table_columns: Option<Vec<crate::completion::TableColumn>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a schema-aware walk and report the completion-engine's
|
||||||
|
/// view (ADR-0024 §Phase D §column-narrowing).
|
||||||
|
#[must_use]
|
||||||
|
pub fn completion_probe(
|
||||||
|
source: &str,
|
||||||
|
schema: &crate::completion::SchemaCache,
|
||||||
|
) -> CompletionProbe {
|
||||||
|
use crate::dsl::grammar::REGISTRY;
|
||||||
|
|
||||||
|
if source.trim().is_empty() {
|
||||||
|
return CompletionProbe {
|
||||||
|
expected: REGISTRY
|
||||||
|
.iter()
|
||||||
|
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||||
|
.collect(),
|
||||||
|
current_table_columns: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut ctx = context::WalkContext::with_schema(schema);
|
||||||
|
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
|
||||||
|
let Some(result) = result else {
|
||||||
|
return CompletionProbe {
|
||||||
|
expected: REGISTRY
|
||||||
|
.iter()
|
||||||
|
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
||||||
|
.collect(),
|
||||||
|
current_table_columns: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let expected = match result.outcome {
|
||||||
|
outcome::WalkOutcome::Match { .. } => result.tail_expected,
|
||||||
|
outcome::WalkOutcome::Incomplete { expected, .. }
|
||||||
|
| outcome::WalkOutcome::Mismatch { expected, .. } => expected,
|
||||||
|
outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(),
|
||||||
|
};
|
||||||
|
CompletionProbe {
|
||||||
|
expected,
|
||||||
|
current_table_columns: ctx.current_table_columns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the grammar would accept at the end of `source`
|
/// What the grammar would accept at the end of `source`
|
||||||
/// (ADR-0024 §architecture, Phase F walker-driven completion).
|
/// (ADR-0024 §architecture, Phase F walker-driven completion).
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user