ADR-0024 Phase D (full): schema-aware value typing

Schema-aware typed value slots — the central design claim of
ADR-0024 §Phase D. Insert / update / delete value slots now
dispatch on the user-facing column type at parse time, rejecting
mis-shaped input with localised wording instead of waiting for
the bind-time error.

What changed:

**SchemaCache extension** (`src/completion.rs`):
- New `TableColumn { name, user_type }` for per-table column
  metadata.
- `SchemaCache.table_columns: HashMap<String, Vec<TableColumn>>`.
- `SchemaCache::columns_for_table(name)` — case-insensitive
  lookup, mirrors the walker's case-insensitive entry-word
  resolution.

**WalkContext schema plumbing** (`src/dsl/walker/context.rs`):
- `WalkContext<'a>` gains a lifetime and a `schema: Option<&'a
  SchemaCache>`. `WalkContext::new()` keeps the schemaless
  default; `with_schema(s)` is the new schema-aware constructor.

**Parser entry point** (`src/dsl/parser.rs`):
- `parse_command_with_schema(input, schema)` is the new public
  schema-aware variant. `parse_command(input)` becomes a thin
  wrapper that delegates with `None` for back-compat.
- Internal `try_walker_route` accepts an `Option<&SchemaCache>`
  and threads it into the WalkContext.

**Node::Ident writes_table/writes_column** (`src/dsl/grammar/mod.rs`):
- Two new fields on `Node::Ident`. When `writes_table: true` and
  `source: Tables`, the walker writes the matched ident's name
  into `current_table` and resolves `current_table_columns`
  against the schema cache. When `writes_column: true` and
  `source: Columns`, the walker writes the resolved
  `TableColumn` into `current_column`.

**Walker driver DynamicSubgrammar dispatch** (`src/dsl/walker/driver.rs`):
- The `Node::DynamicSubgrammar(factory)` branch now resolves the
  factory at walk time and `Box::leak`s the result so its inner
  static-slice fields (Choice/Seq) have the lifetime the walker
  expects (per ADR-0024 §sub-grammars). The leak is bounded by
  command-shape complexity per walk; per-walk arena is a future
  optimisation.
- `walk_ident` extends to perform the schema writes when the
  flags are set.

**Typed value slot factories + dynamic sub-grammars** (`src/dsl/grammar/shared.rs`):
- `int_slot` / `real_slot` / `decimal_slot` / `bool_slot` /
  `text_slot` / `date_slot` / `datetime_slot` / `blob_slot` —
  one per `Type`. Each accepts the appropriate literal kind plus
  `null`; integer-only validator rejects `3.14` at int columns;
  decimal validator pins numeric shape.
- `slot_for_type(ty) -> Node` is the dispatcher.
- `current_column_value(ctx) -> Node` is the dynamic sub-grammar
  for `set col = …` and `where col = …` values; reads
  `current_column` and dispatches via `slot_for_type`.
- `column_value_list(ctx) -> Node` is the dynamic sub-grammar
  for `insert into T values (…)`; reads `current_table_columns`
  and unfolds a Seq of typed slots separated by commas.
- Both fall back to the schemaless `VALUE_LITERAL` choice when
  the context lacks the schema-resolved entries — keeps
  schemaless `parse_command` callers (tests, replay path)
  working.

**Data-command grammar wires the new types** (`src/dsl/grammar/data.rs`):
- `TABLE_NAME_INSERT` / `TABLE_NAME_WRITES` (new): table-name
  slots that set `writes_table: true`. Used by insert / update /
  delete to populate `current_table_columns`.
- `SET_COLUMN` / `FILTER_COLUMN` (new): column-name slots in
  `set col=…` / `where col=…` set `writes_column: true`.
- `INSERT_VALUES_LIST` becomes `DynamicSubgrammar(column_value_list)`.
- `UPDATE_ASSIGNMENT` and `WHERE_CLAUSE` use
  `PER_COLUMN_VALUE = DynamicSubgrammar(current_column_value)`.

**Runtime plumbs schema-with-types** (`src/runtime.rs`):
- `refresh_schema_cache` calls `describe_table` for each table
  and populates `SchemaCache::table_columns` with
  `TableColumn { name, user_type }` entries. Best-effort: a
  `describe_table` miss leaves that table unpopulated and the
  walker falls back to schemaless dispatch.

**App dispatches with schema** (`src/app.rs`):
- `dispatch_dsl` routes through `parse_command_with_schema(&self
  .schema_cache, …)` so live typing/dispatch sees the typed
  slots. The replay path stays schemaless (deferred — replay
  bind-time errors still catch type mismatches).

**Catalog** (`src/friendly/strings/en-US.yaml`, `src/friendly/keys.rs`):
- New `parse.custom.bind_type_mismatch` entry with `{found}` and
  `{expected}` placeholders. Surfaced by the int_slot /
  decimal_slot validators.

Tests:
- 11 new walker-side Phase D tests cover insert / update /
  delete with schemas — typed acceptance per column, decimal
  rejection at int columns, null acceptance at any slot,
  multi-assignment per-column dispatch, schemaless fallback.
- The pre-existing `parse_command(input)` test suite (no
  schema) still passes — the fallback path is behaviour-
  preserving.
- 828 passing total, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-15 17:45:56 +00:00
parent 85817791dc
commit abebd7944f
14 changed files with 754 additions and 74 deletions
+71 -22
View File
@@ -19,6 +19,7 @@
use crate::dsl::command::{Command, RowFilter};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, ValidationError, Word,
shared::{column_value_list, current_column_value},
};
use crate::dsl::value::Value;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
@@ -32,6 +33,21 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
};
/// Table-name slot variant that populates
/// `WalkContext::current_table_columns` (ADR-0024 §Phase D).
/// Used by `insert into <T> …` so the inner value list can
/// dispatch typed slots per column.
const TABLE_NAME_INSERT: Node = Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
};
// `value_literal` — null / true / false / number / string. The
@@ -90,6 +106,8 @@ const INSERT_PAREN_ITEM_CHOICES: &[Node] = &[
role: "insert_first_item",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
},
];
const INSERT_PAREN_ITEM: Node = Node::Choice(INSERT_PAREN_ITEM_CHOICES);
@@ -99,11 +117,12 @@ const INSERT_PAREN_LIST: Node = Node::Repeated {
min: 1,
};
const INSERT_VALUES_LIST: Node = Node::Repeated {
inner: &VALUE_LITERAL,
separator: Some(&Node::Punct(',')),
min: 1,
};
/// Schema-aware value list: when the walker has a populated
/// `current_table_columns`, unfolds to a `Seq` of typed slots
/// per column (`int_slot`, `text_slot`, …). When schemaless,
/// falls back to the pre-Phase-D `Repeated(VALUE_LITERAL, ',', 1)`
/// shape (ADR-0024 §Phase D §column_value_list).
const INSERT_VALUES_LIST: Node = Node::DynamicSubgrammar(column_value_list);
const INSERT_OPTIONAL_VALUES_NODES: &[Node] = &[
Node::Word(Word::keyword("values")),
@@ -135,7 +154,7 @@ const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
Node::Word(Word::keyword("into")),
TABLE_NAME_EXISTING,
TABLE_NAME_INSERT,
INSERT_AFTER_TABLE,
];
const INSERT_SHAPE: Node = Node::Seq(INSERT_NODES);
@@ -144,15 +163,50 @@ const INSERT_SHAPE: Node = Node::Seq(INSERT_NODES);
// update — `update <T> set <col>=<v>[, <col>=<v>] (where … | --all-rows)`
// =================================================================
/// Table-name slot that populates `current_table_columns` so
/// the inner `set <col>=<value>` / `where <col>=<value>` slots
/// can resolve column types (Phase D).
const TABLE_NAME_WRITES: Node = Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
};
/// Column-name slot in `set col = …` — resolves the column's
/// type into `current_column` so the value slot dispatches per
/// column type (Phase D).
const SET_COLUMN: Node = Node::Ident {
source: IdentSource::Columns,
role: "update_set_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: true,
};
/// Column-name slot in `where col = …` — same writes-column
/// semantics as SET_COLUMN, distinct role for the AST builder.
const FILTER_COLUMN: Node = Node::Ident {
source: IdentSource::Columns,
role: "filter_column",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: true,
};
/// Value slot resolved at walk time from
/// `WalkContext::current_column`. Falls back to the schemaless
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Columns,
role: "update_set_column",
validator: None,
highlight_override: None,
},
SET_COLUMN,
Node::Punct('='),
VALUE_LITERAL,
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
@@ -163,14 +217,9 @@ const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
const WHERE_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("where")),
Node::Ident {
source: IdentSource::Columns,
role: "filter_column",
validator: None,
highlight_override: None,
},
FILTER_COLUMN,
Node::Punct('='),
VALUE_LITERAL,
PER_COLUMN_VALUE,
];
const WHERE_CLAUSE: Node = Node::Seq(WHERE_CLAUSE_NODES);
@@ -178,7 +227,7 @@ const FILTER_CHOICES: &[Node] = &[WHERE_CLAUSE, Node::Flag("all-rows")];
const FILTER_CLAUSE: Node = Node::Choice(FILTER_CHOICES);
const UPDATE_NODES: &[Node] = &[
TABLE_NAME_EXISTING,
TABLE_NAME_WRITES,
Node::Word(Word::keyword("set")),
UPDATE_ASSIGNMENTS,
FILTER_CLAUSE,
@@ -191,7 +240,7 @@ const UPDATE_SHAPE: Node = Node::Seq(UPDATE_NODES);
const DELETE_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
TABLE_NAME_EXISTING,
TABLE_NAME_WRITES,
FILTER_CLAUSE,
];
const DELETE_SHAPE: Node = Node::Seq(DELETE_NODES);