//! Shared sub-grammars for the DDL/DML migrations //! (ADR-0024 §architecture, §sub-grammars). //! //! Phase B uses these for relationship endpoints and referential //! actions; Phase D extends with `where_clause`, //! `column_value_list`, and the typed value slots. use crate::completion::TableColumn; use crate::dsl::grammar::{ HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError, Word, }; use crate::dsl::types::Type; use crate::dsl::walker::context::WalkContext; use std::str::FromStr; // --- Type-name validator ------------------------------------------ /// Reject any identifier that isn't a known user-facing type name. /// /// Mirrors the chumsky-side `Type::from_str` + `UnknownType` /// flow — surfaces the same `parse.custom.unknown_type` catalog /// wording with `{found}` and `{expected}` args. pub fn validate_type_name(value: &str) -> Result<(), ValidationError> { if Type::from_str(value).is_ok() { Ok(()) } else { let expected = Type::all() .iter() .map(|t| t.keyword()) .collect::>() .join(", "); Err(ValidationError { message_key: "parse.custom.unknown_type", args: vec![("found", value.to_string()), ("expected", expected)], }) } } pub const TYPE_VALIDATOR: IdentValidator = validate_type_name; // --- Type-slot leaf ----------------------------------------------- /// `Ident` slot for a column type. Validation runs after a /// successful identifier-shape match. pub const TYPE_SLOT: Node = Node::Ident { source: IdentSource::Types, role: "type", validator: Some(TYPE_VALIDATOR), highlight_override: Some(HighlightClass::Type), writes_table: false, writes_column: false, writes_user_listed_column: false, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }; // --- Qualified column reference (`.`) -------------- const QUALIFIED_COLUMN_NODES: &[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, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }, Node::Punct('.'), Node::Ident { source: IdentSource::Columns, role: "column_name", validator: None, highlight_override: None, writes_table: false, writes_column: false, writes_user_listed_column: false, writes_table_alias: false, writes_cte_name: false, writes_projection_alias: false, }, ]; pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); // --- Relationship-endpoint clauses (`from . to .`) ---- const RELATIONSHIP_ENDPOINTS_NODES: &[Node] = &[ Node::Word(Word::keyword("from")), QUALIFIED_COLUMN, Node::Word(Word::keyword("to")), QUALIFIED_COLUMN, ]; pub const RELATIONSHIP_ENDPOINTS: Node = Node::Seq(RELATIONSHIP_ENDPOINTS_NODES); // --- Referential action (`cascade`, `restrict`, `set null`, // `no action`) ----------------------------------------------- const ACTION_SET_NULL: &[Node] = &[ Node::Word(Word::keyword("set")), Node::Word(Word::keyword("null")), ]; const ACTION_NO_ACTION: &[Node] = &[ Node::Word(Word::keyword("no")), Node::Word(Word::keyword("action")), ]; const ACTION_CHOICES: &[Node] = &[ Node::Word(Word::keyword("cascade")), Node::Word(Word::keyword("restrict")), Node::Seq(ACTION_SET_NULL), Node::Seq(ACTION_NO_ACTION), ]; pub const ACTION_KEYWORD: Node = Node::Choice(ACTION_CHOICES); // --- A single `on ` clause ---------------- const ON_TARGET_CHOICES: &[Node] = &[ Node::Word(Word::keyword("delete")), Node::Word(Word::keyword("update")), ]; const ON_CLAUSE_NODES: &[Node] = &[ Node::Word(Word::keyword("on")), Node::Choice(ON_TARGET_CHOICES), ACTION_KEYWORD, ]; pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES); /// Repeated `on ` clauses (0..2 occurrences). /// Validation of "specified twice" + max=2 lives in the /// command's AST builder. pub const REFERENTIAL_CLAUSES: Node = Node::Repeated { inner: &ON_CLAUSE, separator: None, min: 0, }; // ================================================================= // Typed value slots (ADR-0024 §Phase D, §typed-value-slots) // ================================================================= // // Each `_slot()` factory returns a `Node` that accepts either // `null` or a literal of the corresponding shape, with an // optional content validator that rejects mis-typed values at // parse time with localised catalog wording. Per-type prose // hints attach via `Choice` HintMode — but Phase D's first // landing keeps `Default` everywhere; the dispatch-by-column-type // covers the central design claim, and per-type prose can layer // on later without grammar surface changes. fn validate_integer_only(value: &str) -> Result<(), ValidationError> { // The lexer-side number consumer accepts integers and // fractional forms (e.g. `3.14`). For int / serial / shortid // columns reject any literal that carries a decimal point. if value.contains('.') { Err(ValidationError { message_key: "parse.custom.bind_type_mismatch", args: vec![ ("found", value.to_string()), ("expected", "integer".to_string()), ], }) } else { Ok(()) } } const INTEGER_ONLY_VALIDATOR: NumberValidator = validate_integer_only; fn validate_decimal_string(value: &str) -> Result<(), ValidationError> { if value.parse::().is_ok() { Ok(()) } else { Err(ValidationError { message_key: "parse.custom.bind_type_mismatch", args: vec![ ("found", value.to_string()), ("expected", "number".to_string()), ], }) } } const DECIMAL_VALIDATOR: NumberValidator = validate_decimal_string; // Bare `null` keyword — used as the trailing branch of every // typed value slot so a column always accepts the absence sentinel. const NULL_WORD: Node = Node::Word(Word::keyword("null")); const INT_SLOT_CHOICES: &[Node] = &[ Node::NumberLit { validator: Some(INTEGER_ONLY_VALIDATOR), }, NULL_WORD, ]; const INT_SLOT_INNER: Node = Node::Choice(INT_SLOT_CHOICES); const INT_SLOT: Node = Node::TypedValueSlot { ty: Type::Int, column_name: None, inner: &INT_SLOT_INNER, }; const SERIAL_SLOT: Node = Node::TypedValueSlot { ty: Type::Serial, column_name: None, inner: &INT_SLOT_INNER, }; const REAL_SLOT_CHOICES: &[Node] = &[Node::NumberLit { validator: None }, NULL_WORD]; const REAL_SLOT_INNER: Node = Node::Choice(REAL_SLOT_CHOICES); const REAL_SLOT: Node = Node::TypedValueSlot { ty: Type::Real, column_name: None, inner: &REAL_SLOT_INNER, }; const DECIMAL_SLOT_CHOICES: &[Node] = &[ Node::NumberLit { validator: Some(DECIMAL_VALIDATOR), }, NULL_WORD, ]; const DECIMAL_SLOT_INNER: Node = Node::Choice(DECIMAL_SLOT_CHOICES); const DECIMAL_SLOT: Node = Node::TypedValueSlot { ty: Type::Decimal, column_name: None, inner: &DECIMAL_SLOT_INNER, }; const BOOL_SLOT_CHOICES: &[Node] = &[ Node::Word(Word::keyword("true")), Node::Word(Word::keyword("false")), NULL_WORD, ]; const BOOL_SLOT_INNER: Node = Node::Choice(BOOL_SLOT_CHOICES); const BOOL_SLOT: Node = Node::TypedValueSlot { ty: Type::Bool, column_name: None, inner: &BOOL_SLOT_INNER, }; const TEXT_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; const TEXT_SLOT_INNER: Node = Node::Choice(TEXT_SLOT_CHOICES); const TEXT_SLOT: Node = Node::TypedValueSlot { ty: Type::Text, column_name: None, inner: &TEXT_SLOT_INNER, }; // Date / datetime share the StringLit-or-null shape with text // but get distinct catalog-prose entries so the hint surfaces // the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples. const DATE_SLOT: Node = Node::TypedValueSlot { ty: Type::Date, column_name: None, inner: &TEXT_SLOT_INNER, }; const DATETIME_SLOT: Node = Node::TypedValueSlot { ty: Type::DateTime, column_name: None, inner: &TEXT_SLOT_INNER, }; const BLOB_SLOT: Node = Node::TypedValueSlot { ty: Type::Blob, column_name: None, inner: &TEXT_SLOT_INNER, }; // shortid columns store base58 text (ADR-0011 fk_target_type // shortid → text); the slot accepts a quoted-text literal or // null. const SHORTID_SLOT: Node = Node::TypedValueSlot { ty: Type::ShortId, column_name: None, inner: &TEXT_SLOT_INNER, }; /// Dispatch a value slot per user-facing type /// (ADR-0024 §slot_for_type). /// /// Returns the same node every time for a given Type — fine to /// call from within a `DynamicSubgrammar` factory. The /// returned slot does not carry a column name; callers that /// have one (e.g. `column_value_list` building per-column /// slots) should use [`slot_for_column`] instead. #[must_use] pub const fn slot_for_type(ty: Type) -> Node { match ty { Type::Int => INT_SLOT, Type::Serial => SERIAL_SLOT, Type::ShortId => SHORTID_SLOT, Type::Real => REAL_SLOT, Type::Decimal => DECIMAL_SLOT, Type::Bool => BOOL_SLOT, Type::Text => TEXT_SLOT, Type::Date => DATE_SLOT, Type::DateTime => DATETIME_SLOT, Type::Blob => BLOB_SLOT, } } /// Look up just the inner Choice (no `TypedValueSlot` wrapper) /// for a given user-facing type. Used by `slot_for_column` to /// rebuild a TypedValueSlot with an embedded column name. const fn slot_inner_for_type(ty: Type) -> &'static Node { match ty { Type::Int | Type::Serial => &INT_SLOT_INNER, Type::Real => &REAL_SLOT_INNER, Type::Decimal => &DECIMAL_SLOT_INNER, Type::Bool => &BOOL_SLOT_INNER, Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER, } } /// Build a typed value slot with an embedded column name /// (ADR-0024 §Phase D §typed-value-slots). Used by /// `column_value_list` to attach each column's name so the /// hint resolver can render "for ``:" prefixes. /// /// The walker writes the (leaked) name into /// `WalkContext::pending_value_column` on entry to the slot /// and clears it on successful inner match. #[must_use] fn slot_for_column(ty: Type, name: &str) -> Node { // `Box::leak`: column names from the schema cache need a // `&'static str`-compatible lifetime to plug into the // static Node enum. The leak is per dynamic walk (factory // invocation), bounded by the column count — consistent // with the `DynamicSubgrammar` Box::leak in the walker // driver. let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); Node::TypedValueSlot { ty, column_name: Some(leaked), inner: slot_inner_for_type(ty), } } // ================================================================= // Dynamic sub-grammar: column_value_list // ================================================================= /// Fallback when no schema-resolved column list is available /// (schemaless parse, missing table, empty schema cache). /// Mirrors the pre-Phase-D `value_literal` Choice. const FALLBACK_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 FALLBACK_VALUE_LITERAL_INNER: Node = Node::Choice(FALLBACK_VALUE_LITERAL_CHOICES); /// The schemaless value-literal slot. The `ProseOnly` HintMode /// (ADR-0024 §HintMode-per-node) tells the hint resolver to /// surface the generic "Type a value: number, 'text', …" prose /// here rather than the misleading `null`/`true`/`false` /// candidate trio. /// The schemaless value-literal slot. `pub(crate)` so the /// `default ` column constraint (ADR-0029) can reuse /// it from `grammar::ddl`. pub(crate) const FALLBACK_VALUE_LITERAL: Node = Node::Hinted { mode: HintMode::ProseOnly("hint.value_literal_slot"), inner: &FALLBACK_VALUE_LITERAL_INNER, }; /// The type-blind value list. `pub(crate)` so the insert value-list /// arity gate (`data.rs`, issue #17) can route a wrong-count tuple here /// — exactly as the advanced grammar's `tuple_value_list` does — so the /// tuple still structurally matches and the per-tuple arity diagnostic /// (ADR-0033 §8.1) fires instead of a bare "expected `,`/`)`". pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated { inner: &FALLBACK_VALUE_LITERAL, separator: Some(&Node::Punct(',')), min: 1, }; /// The columns an insert value tuple maps onto (ADR-0024 §Phase D). /// /// Mirrors `db::do_insert`'s `user_cols` logic. `None` when the walker /// is schemaless, the table is unknown, or — for Form B/C — every column /// is auto-generated (callers fall back to the type-blind value list). /// /// - **Form A** (`user_listed_columns` set): the listed columns, in the /// user's order; names the schema doesn't know are dropped. /// - **Form B/C** (no column list): the table's non-auto-generated /// columns, in declaration order. `serial` / `shortid` are skipped /// because the simple-mode dispatch auto-fills them (ADR-0018 §3). /// /// This is the single source of truth shared by [`column_value_list`] /// (which builds the typed slots) and the `data.rs` arity gate (which /// counts them) so the two never disagree (issue #17). pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option> { let table_cols = ctx.current_table_columns.as_ref()?; if table_cols.is_empty() { return None; } let cols: Vec<&TableColumn> = ctx.user_listed_columns.as_ref().map_or_else( || { table_cols .iter() .filter(|c| !matches!(c.user_type, Type::Serial | Type::ShortId)) .collect() }, |user_listed| { user_listed .iter() .filter_map(|name| { table_cols .iter() .find(|c| c.name.eq_ignore_ascii_case(name)) }) .collect() }, ); if cols.is_empty() { None } else { Some(cols) } } /// Count the value positions in a `VALUES`/insert tuple whose contents /// begin at `pos` (just past the opening `(`), and whether the tuple is /// *closed* (a depth-0 `)` was reached) vs still being typed (scan hit /// end-of-input first). Depth-aware: commas nested in a function call / /// subquery (paren depth ≥ 1) or inside a string literal are not /// separators. Returns `(0, _)` for an empty tuple `()`. /// /// Shared by the advanced grammar's `tuple_value_list` (`sql_insert.rs`) /// and the simple-mode DSL insert arity gate (`data.rs`) so both modes /// count tuple values identically (issue #17). pub(crate) fn count_tuple_values(source: &str, pos: usize) -> (usize, bool) { let bytes = source.as_bytes(); let mut i = pos; let mut depth: i32 = 0; let mut commas = 0usize; let mut seen_value = false; let mut closed = false; while i < bytes.len() { match bytes[i] { b'\'' => { // Skip a single-quoted string literal (`''` escape). i += 1; seen_value = true; while i < bytes.len() { if bytes[i] == b'\'' { if bytes.get(i + 1) == Some(&b'\'') { i += 2; continue; } i += 1; break; } i += 1; } continue; } b'(' => { depth += 1; seen_value = true; } b')' => { if depth == 0 { closed = true; break; // tuple close } depth -= 1; } b',' if depth == 0 => commas += 1, b if !b.is_ascii_whitespace() => seen_value = true, _ => {} } i += 1; } (if seen_value { commas + 1 } else { 0 }, closed) } /// Value slot keyed on `WalkContext::current_column`. /// /// Picks the typed slot for the column whose name was most /// recently matched by an `Ident { source: Columns, /// writes_column: true }` node (ADR-0024 §Phase D). Fallback /// when no current_column is resolved: the schemaless /// value-literal choice. pub fn current_column_value(ctx: &WalkContext) -> Node { ctx.current_column .as_ref() .map_or(FALLBACK_VALUE_LITERAL, |col| slot_for_type(col.user_type)) } /// Comma-separated list of typed value slots, one per column. /// /// Reads `current_table_columns` from the WalkContext (ADR-0024 /// §Phase D §column_value_list). When the schema cache holds /// no entry for the current table — or the walker is /// schemaless — falls back to the schema-unaware /// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing /// callers/tests continue to work. pub fn column_value_list(ctx: &WalkContext) -> Node { // Target columns per the shared insert mapping (Form A = listed, // Form B/C = non-auto-generated). `None` → schemaless / unknown // table / all-auto-generated → the type-blind fallback list. let Some(target_cols) = insert_target_columns(ctx) else { return FALLBACK_VALUE_LIST; }; // Build a Seq of typed slots interleaved with commas. Each // slot embeds its column name so the hint resolver can // mention the column by name ("for `Email`: Type a quoted // string …"). let mut children: Vec = Vec::with_capacity(target_cols.len() * 2); for (i, col) in target_cols.iter().enumerate() { if i > 0 { children.push(Node::Punct(',')); } children.push(slot_for_column(col.user_type, &col.name)); } Node::Seq(Box::leak(children.into_boxed_slice())) } // ================================================================= // Advanced-mode SQL `SET col = ` value slot (ADR-0036 Phase 3a) // ================================================================= // // A SQL UPDATE / UPSERT `SET col = ` value position routes a // *lone literal* (a string / number / `null` / `true` / `false` that // fills the whole position up to the next `,` / `where` / `returning` // / `;` / end-of-input) to the column-typed slot — so the learner gets // the same per-column hint + numeric-shape highlight the DSL gives — // and routes anything else (an expression: arithmetic, a literal- // prefixed form like `1 + 2`, a function call, a scalar subquery, a // column / `excluded` reference) to the full `sql_expr` grammar, // unchanged (ADR-0030 §4). // // Discrimination is by *lookahead*, NOT `Choice([typed_slot, // sql_expr])`. A naive Choice would let the typed slot greedily match // the leading literal of `1 + 2` and commit, leaving `+ 2` dangling — // the walker's `Choice` is first-match-wins with no cross-branch // backtrack — turning a valid expression into a parse error. The // lookahead peeks the whole value position first, so a literal routes // to the typed slot only when it is the *entire* value (ADR-0036 // Amendment 1). /// Lookahead factory for a SQL `SET col = ` value position /// (ADR-0036 Phase 3a). Routes a lone literal to the column-typed slot /// (live hint + highlight) and everything else to `sql_expr`. fn set_value_node(_ctx: &WalkContext, source: &str, pos: usize) -> Node { if set_rhs_is_lone_literal(source, pos) { // The column type was resolved into `current_column` by the // preceding `SET` column ident (`writes_column: true`), so the // typed slot drives the per-column hint + highlight. Node::DynamicSubgrammar(current_column_value) } else { // An expression — the engine evaluates it; keep the full // `sql_expr` surface. Returned from a `fn` (not a `const`) so // the reference doesn't enter the sql_expr ⇄ sql_select // const-evaluation cycle (see the matching note in `data.rs`). Node::Subgrammar(&crate::dsl::grammar::sql_expr::SQL_OR_EXPR) } } /// The SQL `SET col = ` value slot (ADR-0036 Phase 3a). Shared by /// `sql_update`'s assignment list and the `sql_insert` UPSERT /// `DO UPDATE SET`. pub const SET_VALUE: Node = Node::Lookahead(set_value_node); /// True when the `SET` RHS starting at `pos` is empty, a partial string /// still being typed, or exactly one complete literal token that fills /// the value position (the next token is a position boundary). Such /// positions route to the column-typed slot so its hint/highlight fire; /// everything else is an expression and routes to `sql_expr`. /// /// An empty RHS counts as lone-literal so the typed-slot hint shows /// while the cursor sits right after `=`. A signed number (`-5`) counts /// too: `consume_number_literal` — the same consumer the slot's /// `NumberLit` uses — folds the leading sign into the literal, so the /// slot can match it. fn set_rhs_is_lone_literal(source: &str, pos: usize) -> bool { use crate::dsl::walker::lex_helpers::{ consume_ident, consume_number_literal, consume_string_literal, skip_whitespace, }; let p = skip_whitespace(source, pos); if p >= source.len() { return true; // empty RHS — show the typed-slot hint } // A string literal — single-quoted (SQL strings). Complete (a // boundary must follow) or a partial one still being typed (route // to the slot so the hint persists while typing). if source.as_bytes()[p] == b'\'' { return match consume_string_literal(source, p) { Some(((_, end), _)) => next_is_set_boundary(source, end), None => true, // unterminated string, mid-typing }; } // A number literal (incl. a leading sign) that fills the position. if let Some((_, end)) = consume_number_literal(source, p) { return next_is_set_boundary(source, end); } // `null` / `true` / `false` filling the position. if let Some((s, e)) = consume_ident(source, p) { let word = &source[s..e]; if word.eq_ignore_ascii_case("null") || word.eq_ignore_ascii_case("true") || word.eq_ignore_ascii_case("false") { return next_is_set_boundary(source, e); } return false; // any other identifier → column ref / function → expression } false // punctuation / sign-then-space / `(` → expression } /// True when the next non-whitespace token after `pos` ends a `SET` /// value position: `,` (next assignment), `)` / `;` / end-of-input, or /// a `where` / `returning` clause keyword. fn next_is_set_boundary(source: &str, pos: usize) -> bool { use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let p = skip_whitespace(source, pos); let Some(b) = source.as_bytes().get(p) else { return true; // end of input }; if *b == b',' || *b == b';' || *b == b')' { return true; } if let Some((s, e)) = consume_ident(source, p) { let word = &source[s..e]; return word.eq_ignore_ascii_case("where") || word.eq_ignore_ascii_case("returning"); } false }