//! Data command nodes (ADR-0024 §migration Phase D). //! //! Five commands at four entry words: `show` (show data / //! show table), `insert`, `update`, `delete`. The walker route //! owns these end-to-end. //! //! Phase D scope deviation note: ADR-0024's Phase D describes //! "full schema awareness" via `DynamicSubgrammar //! (column_value_list)` that unfolds typed slots per column. This //! milestone lands the data commands at functional parity with //! the existing chumsky parser — value slots accept any //! literal regardless of column type, with type validation //! happening at bind time (matching today's behaviour). The //! `DynamicSubgrammar` machinery and schema-cache plumbing are //! deferred to a follow-up refinement; the trie shape is //! ready to consume them when the schema reference flows //! through `parse_command`. use crate::dsl::command::{Command, RowFilter}; use crate::dsl::grammar::{ CommandNode, HintMode, IdentSource, Node, ValidationError, Word, shared::{column_value_list, current_column_value}, }; use crate::dsl::value::Value; use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath}; // ================================================================= // Building blocks // ================================================================= const TABLE_NAME_EXISTING: Node = Node::Ident { source: IdentSource::Tables, role: "table_name", validator: None, highlight_override: None, writes_table: false, writes_column: false, writes_user_listed_column: false, }; /// Table-name slot variant that populates /// `WalkContext::current_table_columns` (ADR-0024 §Phase D). /// Used by `insert into …` 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, writes_user_listed_column: false, }; // `value_literal` — null / true / false / number / string. The // chumsky-side equivalent (`value_literal()` in dsl/parser.rs). const VALUE_LITERAL_CHOICES: &[Node] = &[ Node::Word(Word::keyword("null")), Node::Word(Word::keyword("true")), Node::Word(Word::keyword("false")), Node::NumberLit { validator: None }, Node::StringLit, ]; const VALUE_LITERAL_INNER: Node = Node::Choice(VALUE_LITERAL_CHOICES); /// Value-literal slot with the `ProseOnly` HintMode /// (ADR-0024 §HintMode-per-node) — the hint resolver surfaces /// the generic "Type a value: …" prose rather than the /// misleading `null`/`true`/`false` candidate trio. const VALUE_LITERAL: Node = Node::Hinted { mode: HintMode::ProseOnly("hint.value_literal_slot"), inner: &VALUE_LITERAL_INNER, }; // ================================================================= // show — `show (data|table) ` // ================================================================= const SHOW_DATA_NODES: &[Node] = &[ Node::Word(Word::keyword("data")), TABLE_NAME_EXISTING, ]; const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES); const SHOW_TABLE_NODES: &[Node] = &[ Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING, ]; const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES); const SHOW_CHOICES: &[Node] = &[SHOW_DATA, SHOW_TABLE]; const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES); // ================================================================= // insert — `insert into (,,…) values (,,…)` // | `insert into values (,…)` // | `insert into (,…)` // ================================================================= // // Forms A (with column list) and C (bare value list) both start // with `(`. To avoid the walker's "first commit wins" semantics // rejecting Form C when the inner content is values rather than // column names, the inside of the first paren is parsed as a // repeated `Choice(Ident, ValueLiteral)`. The AST builder then // disambiguates: if a `values` keyword follows the first paren, // the inner content was column names; otherwise it was values. const INSERT_PAREN_ITEM_CHOICES: &[Node] = &[ // VALUE_LITERAL first so that `true`/`false`/`null` match // their Word branch rather than the broader Ident{Columns} // catch-all (consume_ident doesn't filter against the // keyword set; without this ordering, `(true)` would lex // as a column-name list). VALUE_LITERAL, Node::Ident { source: IdentSource::Columns, role: "insert_first_item", validator: None, highlight_override: None, writes_table: false, writes_column: false, // Form A signal: when the user lists explicit columns // in `insert into (col1, col2, …)`, the walker // appends each matched name to // `WalkContext::user_listed_columns`. The inner // `values (…)` slot list then mirrors that user // selection instead of the auto-filtered default // (ADR-0024 §Phase D §column_value_list). writes_user_listed_column: true, }, ]; const INSERT_PAREN_ITEM: Node = Node::Choice(INSERT_PAREN_ITEM_CHOICES); const INSERT_PAREN_LIST: Node = Node::Repeated { inner: &INSERT_PAREN_ITEM, 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")), Node::Punct('('), INSERT_VALUES_LIST, Node::Punct(')'), ]; const INSERT_OPTIONAL_VALUES: Node = Node::Optional(&Node::Seq(INSERT_OPTIONAL_VALUES_NODES)); const INSERT_PAREN_FIRST_NODES: &[Node] = &[ Node::Punct('('), INSERT_PAREN_LIST, Node::Punct(')'), INSERT_OPTIONAL_VALUES, ]; const INSERT_PAREN_FIRST: Node = Node::Seq(INSERT_PAREN_FIRST_NODES); const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[ Node::Word(Word::keyword("values")), Node::Punct('('), INSERT_VALUES_LIST, Node::Punct(')'), ]; const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES); const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST]; const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES); const INSERT_NODES: &[Node] = &[ Node::Word(Word::keyword("into")), TABLE_NAME_INSERT, INSERT_AFTER_TABLE, ]; const INSERT_SHAPE: Node = Node::Seq(INSERT_NODES); // ================================================================= // update — `update set =[, =] (where … | --all-rows)` // ================================================================= /// Table-name slot that populates `current_table_columns` so /// the inner `set =` / `where =` 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, writes_user_listed_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, writes_user_listed_column: false, }; /// 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, writes_user_listed_column: false, }; /// 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] = &[ SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE, ]; const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES); const UPDATE_ASSIGNMENTS: Node = Node::Repeated { inner: &UPDATE_ASSIGNMENT, separator: Some(&Node::Punct(',')), min: 1, }; const WHERE_CLAUSE_NODES: &[Node] = &[ Node::Word(Word::keyword("where")), FILTER_COLUMN, Node::Punct('='), PER_COLUMN_VALUE, ]; const WHERE_CLAUSE: Node = Node::Seq(WHERE_CLAUSE_NODES); const FILTER_CHOICES: &[Node] = &[WHERE_CLAUSE, Node::Flag("all-rows")]; const FILTER_CLAUSE: Node = Node::Choice(FILTER_CHOICES); const UPDATE_NODES: &[Node] = &[ TABLE_NAME_WRITES, Node::Word(Word::keyword("set")), UPDATE_ASSIGNMENTS, FILTER_CLAUSE, ]; const UPDATE_SHAPE: Node = Node::Seq(UPDATE_NODES); // ================================================================= // delete — `delete from (where … | --all-rows)` // ================================================================= const DELETE_NODES: &[Node] = &[ Node::Word(Word::keyword("from")), TABLE_NAME_WRITES, FILTER_CLAUSE, ]; const DELETE_SHAPE: Node = Node::Seq(DELETE_NODES); // ================================================================= // AST builders // ================================================================= fn ident_text<'a>(path: &'a MatchedPath, role: &str) -> Option<&'a str> { path.items.iter().find_map(|i| match &i.kind { MatchedKind::Ident { role: r } if *r == role => Some(i.text.as_str()), _ => None, }) } fn require_ident(path: &MatchedPath, role: &'static str) -> Result { ident_text(path, role) .map(str::to_string) .ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", format!("missing {role}"))], }) } /// Convert a `MatchedItem` whose kind is one of the `value_literal` /// variants (Word("null"|"true"|"false"), NumberLit, StringLit) to /// a `Value`. Returns None for non-value items. fn item_to_value(item: &MatchedItem) -> Option { match &item.kind { MatchedKind::Word("null") => Some(Value::Null), MatchedKind::Word("true") => Some(Value::Bool(true)), MatchedKind::Word("false") => Some(Value::Bool(false)), MatchedKind::NumberLit => Some(Value::Number(item.text.clone())), MatchedKind::StringLit => Some(Value::Text(item.text.clone())), _ => None, } } fn build_show(path: &MatchedPath) -> Result { let sub = path .items .iter() .filter_map(|i| match &i.kind { MatchedKind::Word(w) => Some(*w), _ => None, }) .nth(1); let name = require_ident(path, "table_name")?; match sub { Some("data") => Ok(Command::ShowData { name }), Some("table") => Ok(Command::ShowTable { name }), _ => Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown show subcommand".to_string())], }), } } fn build_insert(path: &MatchedPath) -> Result { let table = require_ident(path, "table_name")?; // Locate the second `values` keyword (the first is the // command word `insert`'s sibling — but `insert` isn't a // matched Word here since it's the entry word and the // entry-word push uses the literal "insert"; only later // `values` matches as Word("values")). // // Strategy: walk the path. After the table name: // - If we see Word("values") next (Form B), the next // parenthesized values are the value list. // - If we see Punct('('), the first paren's content was // either column names (Form A) or values (Form C). // If a Word("values") follows the closing paren, it's // Form A. // // Easier discriminator: collect all matched keyword words; // count occurrences of "values". let saw_values = path .items .iter() .any(|i| matches!(i.kind, MatchedKind::Word("values"))); // Find the index of the table_name match — the first paren // afterwards starts the parsed list. let table_idx = path .items .iter() .position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name" })) .ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing table".to_string())], })?; // Form B (values keyword right after table): no column list, // values come from the single paren-bounded list. let first_token_after_table = path.items.get(table_idx + 1); let form_b = matches!( first_token_after_table.map(|i| &i.kind), Some(MatchedKind::Word("values")) ); if form_b { // Form B: the only value run is between the only `(` … `)`. let values = collect_values_in_parens(path, table_idx + 1)?; return Ok(Command::Insert { table, columns: None, values, }); } // Form A or C: the first paren after the table is a Choice // of either column-idents or value-literals. let first_paren_idx = path .items .iter() .enumerate() .skip(table_idx + 1) .find(|(_, i)| matches!(i.kind, MatchedKind::Punct('('))) .map(|(idx, _)| idx) .ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing `(`".to_string())], })?; if saw_values { // Form A: first paren = column names; second paren = values. // The Repeated inside the first paren tagged matched idents // with role "insert_first_item". let columns: Vec = path .items .iter() .filter_map(|i| match &i.kind { MatchedKind::Ident { role: "insert_first_item", } => Some(i.text.clone()), _ => None, }) .collect(); if columns.is_empty() { return Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "expected column names in `insert into T (…)`".to_string())], }); } // Find the `values` keyword and the next `(` — the values // run starts after that `(`. let values_idx = path .items .iter() .enumerate() .skip(first_paren_idx) .find(|(_, i)| matches!(i.kind, MatchedKind::Word("values"))) .map(|(i, _)| i) .ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing `values` keyword".to_string())], })?; let values = collect_values_in_parens(path, values_idx + 1)?; Ok(Command::Insert { table, columns: Some(columns), values, }) } else { // Form C: the first paren contained the value list. The // Repeated tagged the matched values via their natural // MatchedKind (Word/NumberLit/StringLit); collect them. // // Form-A-without-`values` recovery: the shared // INSERT_PAREN_ITEM choice accepts both VALUE_LITERAL // and Ident{Columns} so that Form A can resolve // column-name items inside its `( cols )` list. When the // user types `insert into T (col)` (column-shaped item, // no `values` keyword), the grammar walks to a complete // match but the user almost certainly meant Form A and // forgot the `values (...)` suffix. Reject here with a // ValidationError — the walker classifies validation // errors as `at_eof: true`, so the input renderer // surfaces this as IncompleteAtEof (mid-typing) rather // than dispatching a logically-broken Form C insert with // an empty value list. let user_listed_columns: Vec = path .items .iter() .filter_map(|i| match &i.kind { MatchedKind::Ident { role: "insert_first_item", } => Some(i.text.clone()), _ => None, }) .collect(); if !user_listed_columns.is_empty() { return Err(ValidationError { message_key: "parse.custom.insert_form_a_missing_values", args: vec![("columns", user_listed_columns.join(", "))], }); } let values = collect_values_in_parens(path, first_paren_idx)?; Ok(Command::Insert { table, columns: None, values, }) } } /// Collect Value items inside the next `(…)` block at or after /// `start_idx`. Stops at the matching `)`. fn collect_values_in_parens( path: &MatchedPath, start_idx: usize, ) -> Result, ValidationError> { let mut out = Vec::new(); let mut inside = false; for item in path.items.iter().skip(start_idx) { match &item.kind { MatchedKind::Punct('(') => inside = true, MatchedKind::Punct(')') if inside => return Ok(out), _ if inside => { if let Some(v) = item_to_value(item) { out.push(v); } } _ => {} } } if out.is_empty() && !inside { return Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing `(`".to_string())], }); } Ok(out) } fn build_update(path: &MatchedPath) -> Result { let table = require_ident(path, "table_name")?; let assignments = collect_assignments(path)?; let filter = collect_filter(path)?; Ok(Command::Update { table, assignments, filter, }) } fn collect_assignments( path: &MatchedPath, ) -> Result, ValidationError> { let mut out = Vec::new(); let mut iter = path.items.iter(); while let Some(item) = iter.next() { if matches!( item.kind, MatchedKind::Ident { role: "update_set_column" } ) { let column = item.text.clone(); // Skip the `=` punct. for next in iter.by_ref() { if matches!(next.kind, MatchedKind::Punct('=')) { break; } } // Next item is the value. let value_item = iter.next().ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing assignment value".to_string())], })?; let value = item_to_value(value_item).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "expected value literal".to_string())], })?; out.push((column, value)); } } Ok(out) } fn collect_filter(path: &MatchedPath) -> Result { if path .items .iter() .any(|i| matches!(i.kind, MatchedKind::Flag("all-rows"))) { return Ok(RowFilter::AllRows); } // Walk for filter_column ident, then `=`, then value. let mut iter = path.items.iter(); while let Some(item) = iter.next() { if matches!( item.kind, MatchedKind::Ident { role: "filter_column" } ) { let column = item.text.clone(); // Skip until `=`. for next in iter.by_ref() { if matches!(next.kind, MatchedKind::Punct('=')) { break; } } let value_item = iter.next().ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing where value".to_string())], })?; let value = item_to_value(value_item).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "expected value literal".to_string())], })?; return Ok(RowFilter::Where { column, value }); } } Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing where or --all-rows".to_string())], }) } fn build_delete(path: &MatchedPath) -> Result { let table = require_ident(path, "table_name")?; let filter = collect_filter(path)?; Ok(Command::Delete { table, filter }) } // ================================================================= // replay — `replay ` | `replay ''` // ================================================================= // // Phase E (ADR-0024 §migration). The chumsky-side // `try_parse_replay_with_bare_path` source-slice helper is // retired here: walker BarePath consumes the unquoted form // (terminating at whitespace per the path-bearing UX change), // and StringLit consumes the quoted form. Paths with spaces // must use the quoted form — same UX that `import` / `export` // adopted in Phase A. const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath]; const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES); fn build_replay(path: &MatchedPath) -> Result { let payload = path .items .iter() .find_map(|i| match &i.kind { MatchedKind::StringLit | MatchedKind::BarePath => Some(i.text.clone()), _ => None, }) .ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "missing path".to_string())], })?; Ok(Command::Replay { path: payload }) } // ================================================================= // CommandNodes // ================================================================= pub static SHOW: CommandNode = CommandNode { entry: Word::keyword("show"), shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), usage_ids: &["parse.usage.show_data", "parse.usage.show_table"], hint_mode: None, }; pub static INSERT: CommandNode = CommandNode { entry: Word::keyword("insert"), shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), usage_ids: &["parse.usage.insert"], hint_mode: None, }; pub static UPDATE: CommandNode = CommandNode { entry: Word::keyword("update"), shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), usage_ids: &["parse.usage.update"], hint_mode: None, }; pub static DELETE: CommandNode = CommandNode { entry: Word::keyword("delete"), shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), usage_ids: &["parse.usage.delete"], hint_mode: None, }; pub static REPLAY: CommandNode = CommandNode { entry: Word::keyword("replay"), shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), usage_ids: &["parse.usage.replay"], hint_mode: None, };