walker: schema-existence ERROR diagnostics (ADR-0027 step B)

`MatchedKind::Ident` now carries its `IdentSource`. A
post-walk pass over a structurally-valid parse flags a
matched `Tables` ident that is absent from the schema, or a
`Columns` ident absent from the table in scope, as an ERROR
diagnostic — the command parses but would fail at execution
(ADR-0027 §2). New behaviour: an unknown table / column used
to parse cleanly and fail only when run.

Column scope is resolved by one left-to-right pass over the
matched path (every command places its table ident before
the columns that belong to it); an unknown table clears the
scope, so its columns are not cascaded into a second
diagnostic. New catalog keys `diagnostic.unknown_table` /
`diagnostic.unknown_column`.
This commit is contained in:
claude@clouddev1
2026-05-19 07:15:58 +00:00
parent e22f933e02
commit 827b47f88f
9 changed files with 169 additions and 15 deletions
+137 -3
View File
@@ -330,6 +330,97 @@ pub fn input_verdict(
outcome_severity.into_iter().chain(diag_severity).max()
}
/// Schema-existence diagnostics (ADR-0027 §2).
///
/// A matched `IdentSource::Tables` token whose name is not in
/// the schema — or a `Columns` token absent from the table in
/// scope — is an ERROR: the command parses but would fail at
/// execution. Runs only on a structural `Match`.
///
/// Column scope is resolved by a single left-to-right pass:
/// every command places its table ident before the columns
/// that belong to it (a qualified `T.c` puts `T` immediately
/// before `c`), so the most recent valid `Tables` ident is the
/// table a subsequent `Columns` ident is checked against. An
/// unknown table clears the scope, so its columns are not
/// cascaded into a second diagnostic.
fn schema_existence_diagnostics(
path: &MatchedPath,
schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
use crate::dsl::grammar::IdentSource;
use outcome::{Diagnostic, MatchedKind, Severity};
let Some(schema) = schema else {
return Vec::new();
};
let mut diagnostics = Vec::new();
let mut current_table: Option<String> = None;
for item in &path.items {
let MatchedKind::Ident { source, .. } = item.kind else {
continue;
};
match source {
IdentSource::Tables => {
if schema_has_table(schema, &item.text) {
current_table = Some(item.text.clone());
} else {
current_table = None;
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: item.span,
message: crate::friendly::translate(
"diagnostic.unknown_table",
&[("name", &item.text as &dyn std::fmt::Display)],
),
});
}
}
IdentSource::Columns => {
if let Some(table) = current_table.as_deref()
&& !schema_has_column(schema, table, &item.text)
{
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: item.span,
message: crate::friendly::translate(
"diagnostic.unknown_column",
&[
("name", &item.text as &dyn std::fmt::Display),
("table", &table as &dyn std::fmt::Display),
],
),
});
}
}
// Invented names (`NewName`), closed sets (`Types`),
// and the other entity kinds are not schema-checked
// here (ADR-0027 §2 scopes the check to tables and
// columns).
IdentSource::NewName
| IdentSource::Relationships
| IdentSource::Indexes
| IdentSource::Types
| IdentSource::Free => {}
}
}
diagnostics
}
fn schema_has_table(schema: &crate::completion::SchemaCache, name: &str) -> bool {
schema.tables.iter().any(|t| t.eq_ignore_ascii_case(name))
}
fn schema_has_column(
schema: &crate::completion::SchemaCache,
table: &str,
column: &str,
) -> bool {
schema
.columns_for_table(table)
.is_some_and(|cols| cols.iter().any(|c| c.name.eq_ignore_ascii_case(column)))
}
/// What the grammar would accept at the end of `source`
/// (ADR-0024 §architecture, Phase F walker-driven completion).
///
@@ -604,14 +695,20 @@ pub fn walk<'a>(
other => (other, None),
};
// Schema-existence diagnostics (ADR-0027 §2) layer on top
// of a structurally-valid parse; a parse that already
// failed gets its ERROR verdict from `outcome`.
let diagnostics = if matches!(final_outcome, WalkOutcome::Match { .. }) {
schema_existence_diagnostics(&path, ctx.schema)
} else {
Vec::new()
};
let result = WalkResult {
outcome: final_outcome,
matched_path: path,
per_byte_class: per_byte,
tail_expected,
// Filled by the schema-existence and expression passes
// (ADR-0027 §2); empty here at the structural layer.
diagnostics: Vec::new(),
diagnostics,
};
(Some(result), cmd)
}
@@ -1317,6 +1414,43 @@ mod tests {
);
}
#[test]
fn input_verdict_unknown_table_is_error() {
// The command parses, but the table does not exist —
// an ERROR diagnostic (ADR-0027 §2).
let schema = schema_with("Customers", &[("id", Type::Int)]);
assert_eq!(
super::input_verdict("show data NoSuchTable", Some(&schema)),
Some(super::Severity::Error),
);
}
#[test]
fn input_verdict_unknown_column_is_error() {
let schema =
schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
assert_eq!(
super::input_verdict(
"show data Customers where NoSuchCol = 1",
Some(&schema),
),
Some(super::Severity::Error),
);
}
#[test]
fn input_verdict_known_table_and_column_is_clean() {
let schema =
schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
assert_eq!(
super::input_verdict(
"show data Customers where id = 1",
Some(&schema),
),
None,
);
}
#[test]
fn walker_parses_insert_with_explicit_column_list() {
assert_eq!(