4a5fd1b5c1
The first exemplar (`add 1:n relationship`) showed per-node keying is too coarse for multi-form commands, so revise the mechanism to per-form. - CommandNode `hint_id: Option<&str>` -> `hint_ids: &[&str]` (mirrors usage_ids); hint_key_for_input_in_mode reuses a factored-out pick_form_key (shared digit/m:n/suffix form disambiguation with usage_key_for_input_in_mode) - wire INSERT + ADD (all four forms) with hint_ids - author the three approved exemplars: hint.cmd.insert, hint.cmd.add_relationship, hint.err.foreign_key.child_side (what/example/concept) + keys.rs registration - revise ADR-0053 D3 to per-form; record clause-concept hints as a deferred extension (issue #37); update README + plan - +5 tests; 2488 pass / 1 ignored, clippy clean
3451 lines
132 KiB
Rust
3451 lines
132 KiB
Rust
//! DDL command nodes (ADR-0024 §migration Phase B).
|
||
//!
|
||
//! Five commands at four entry words: `drop` (drop table /
|
||
//! drop column / drop relationship), `add` (add column /
|
||
//! add 1:n relationship), `rename` (rename column), `change`
|
||
//! (change column). The chumsky-side declarations stay
|
||
//! reachable for any input the walker doesn't engage on, but
|
||
//! for these entry words the walker is authoritative.
|
||
//!
|
||
//! Each shape is laid out inline so per-use-site `role`
|
||
//! annotations carry meaning end-to-end (e.g.,
|
||
//! `parent_table` vs `child_table` for the endpoints clause).
|
||
|
||
use crate::dsl::action::ReferentialAction;
|
||
use crate::dsl::command::{
|
||
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
||
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
|
||
};
|
||
use crate::dsl::value::Value;
|
||
use crate::dsl::grammar::{
|
||
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
|
||
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
||
};
|
||
|
||
/// `HintMode` annotation shared by every `NewName` ident slot:
|
||
/// the user is inventing a name, so the hint panel forces the
|
||
/// "Type a name [then …]" prose rather than offering schema
|
||
/// candidates (ADR-0024 §HintMode-per-node).
|
||
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
|
||
use crate::dsl::types::Type;
|
||
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
|
||
|
||
// =================================================================
|
||
// Building blocks
|
||
// =================================================================
|
||
|
||
const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
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,
|
||
};
|
||
const TABLE_NAME_NEW: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &TABLE_NAME_NEW_IDENT,
|
||
};
|
||
|
||
// `writes_table: true` so that the column-name slots that
|
||
// follow the table name in `drop column` / `rename column` /
|
||
// `change column` / `add column` can narrow their candidates to
|
||
// this table's columns (handoff-12 §2.2). The walker writes
|
||
// `current_table` / `current_table_columns` on match; the
|
||
// completion engine reads the snapshot. `drop table` has no
|
||
// downstream column slot, so the write is harmless there.
|
||
const TABLE_NAME_EXISTING: 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,
|
||
writes_table_alias: false,
|
||
writes_cte_name: false,
|
||
writes_projection_alias: false,
|
||
};
|
||
|
||
const COLUMN_NAME: Node = 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,
|
||
};
|
||
|
||
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
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,
|
||
};
|
||
const COLUMN_NAME_NEW: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &COLUMN_NAME_NEW_IDENT,
|
||
};
|
||
|
||
const RELATIONSHIP_NAME: Node = Node::Ident {
|
||
source: IdentSource::Relationships,
|
||
role: "relationship_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,
|
||
};
|
||
|
||
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "relationship_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,
|
||
};
|
||
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &RELATIONSHIP_NAME_NEW_IDENT,
|
||
};
|
||
|
||
const INDEX_NAME_EXISTING: Node = Node::Ident {
|
||
source: IdentSource::Indexes,
|
||
role: "index_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,
|
||
};
|
||
|
||
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "index_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,
|
||
};
|
||
const INDEX_NAME_NEW: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &INDEX_NAME_NEW_IDENT,
|
||
};
|
||
|
||
// The column list shared by `add index` / `drop index`: one or
|
||
// more existing column names, comma-separated, inside parens.
|
||
// `COLUMN_NAME` narrows to the `on <Table>` table's columns
|
||
// because that ident carries `writes_table: true`.
|
||
const INDEX_COLUMN_LIST: Node = Node::Repeated {
|
||
inner: &COLUMN_NAME,
|
||
separator: Some(&Node::Punct(',')),
|
||
min: 1,
|
||
};
|
||
|
||
// `[to]` and `[table]` connectives.
|
||
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
|
||
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
|
||
const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in")));
|
||
const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
|
||
|
||
// =================================================================
|
||
// drop_table — `drop table <T>`
|
||
// =================================================================
|
||
|
||
const DROP_TABLE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("table")),
|
||
TABLE_NAME_EXISTING,
|
||
];
|
||
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||
|
||
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||
// sub-phase 4c). Same table-only target as the simple `drop table`,
|
||
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
|
||
// `table` keyword (not the Optional) keeps the element/dispatch
|
||
// matching honest.
|
||
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
|
||
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
|
||
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
|
||
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("table")),
|
||
SQL_DROP_IF_EXISTS_OPT,
|
||
TABLE_NAME_EXISTING,
|
||
Node::Optional(&Node::Punct(';')),
|
||
];
|
||
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
|
||
|
||
// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
|
||
// form (that stays the simple `drop index on …`, which falls back to
|
||
// the simple `drop` node). Leads on the concrete `index` keyword; the
|
||
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
|
||
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
|
||
// still parses and reaches the skip path.
|
||
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("index")),
|
||
SQL_DROP_IF_EXISTS_OPT,
|
||
INDEX_NAME_EXISTING,
|
||
Node::Optional(&Node::Punct(';')),
|
||
];
|
||
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);
|
||
|
||
// =================================================================
|
||
// drop_column — `drop column [from] [table] <T> : <col>`
|
||
// =================================================================
|
||
|
||
// `--cascade` (ADR-0025): opt-in to dropping any index that
|
||
// covers the column alongside the column itself. Without it, a
|
||
// covered column is refused with a friendly error.
|
||
const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade"));
|
||
|
||
const DROP_COLUMN_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
FROM_OPT,
|
||
TABLE_OPT,
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct(':'),
|
||
COLUMN_NAME,
|
||
DROP_COLUMN_CASCADE_OPT,
|
||
];
|
||
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
|
||
|
||
// =================================================================
|
||
// drop_relationship — `drop relationship (endpoints | name)`
|
||
// =================================================================
|
||
|
||
// `writes_table: true` on each endpoint's table ident so the
|
||
// `.<col>` slot that follows narrows to that table's columns
|
||
// (handoff-13 §2.2 follow-up). The two endpoints are walked
|
||
// sequentially, so `current_table` is correctly the parent
|
||
// table while walking `parent_column` and the child table
|
||
// while walking `child_column`.
|
||
const DR_PARENT_NODES: &[Node] = &[
|
||
Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "parent_table",
|
||
validator: None,
|
||
highlight_override: None,
|
||
writes_table: true,
|
||
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: "parent_column",
|
||
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,
|
||
},
|
||
];
|
||
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
|
||
|
||
const DR_CHILD_NODES: &[Node] = &[
|
||
Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "child_table",
|
||
validator: None,
|
||
highlight_override: None,
|
||
writes_table: true,
|
||
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: "child_column",
|
||
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,
|
||
},
|
||
];
|
||
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
|
||
|
||
const DR_ENDPOINTS_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("from")),
|
||
DR_PARENT,
|
||
Node::Word(Word::keyword("to")),
|
||
DR_CHILD,
|
||
];
|
||
const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
|
||
|
||
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
|
||
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
|
||
|
||
const DROP_RELATIONSHIP_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("relationship")),
|
||
DR_SELECTOR,
|
||
];
|
||
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
|
||
|
||
// =================================================================
|
||
// drop_index — `drop index (<name> | on <T> (<col>, …))`
|
||
// =================================================================
|
||
|
||
const DI_POSITIONAL_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("on")),
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct('('),
|
||
INDEX_COLUMN_LIST,
|
||
Node::Punct(')'),
|
||
];
|
||
const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
|
||
|
||
// Positional form first — it opens with the `on` keyword, so a
|
||
// bare index name can't be mistaken for it (mirrors DR_SELECTOR).
|
||
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
|
||
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
|
||
|
||
const DROP_INDEX_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("index")),
|
||
DI_SELECTOR,
|
||
];
|
||
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
|
||
|
||
// =================================================================
|
||
// drop entry — `drop (table|column|relationship|index) ...`
|
||
// =================================================================
|
||
|
||
const DROP_CHOICES: &[Node] =
|
||
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
|
||
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
|
||
|
||
// =================================================================
|
||
// add_column — `add column [to] [table] <T> : <col> ( <type> )`
|
||
// =================================================================
|
||
|
||
const ADD_COLUMN_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
TO_OPT,
|
||
TABLE_OPT,
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct(':'),
|
||
COLUMN_NAME_NEW,
|
||
Node::Punct('('),
|
||
TYPE_SLOT,
|
||
Node::Punct(')'),
|
||
// ADR-0029: the constraint suffix — shared with `create
|
||
// table`'s column spec.
|
||
COLUMN_CONSTRAINT_SUFFIX,
|
||
];
|
||
const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
|
||
|
||
// =================================================================
|
||
// add_relationship — `add 1:n relationship [as <name>]
|
||
// from <T>.<c> to <T>.<c>
|
||
// [on delete <a>] [on update <a>]
|
||
// [--create-fk]`
|
||
// =================================================================
|
||
|
||
// `writes_table: true` on each endpoint's table ident so the
|
||
// `.<col>` slot narrows to that table's columns (handoff-13
|
||
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
|
||
// A single FK-endpoint column ident (narrows to the endpoint
|
||
// table's columns via the table ident's `writes_table: true`).
|
||
const AR_PARENT_COL: Node = Node::Ident {
|
||
source: IdentSource::Columns,
|
||
role: "parent_column",
|
||
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,
|
||
};
|
||
// Compound endpoint: `( a, b, … )` — a comma-separated column list
|
||
// in parens (ADR-0043). Same role as the single form, so the
|
||
// builder collects either shape uniformly.
|
||
const AR_PARENT_COL_LIST: Node = Node::Repeated {
|
||
inner: &AR_PARENT_COL,
|
||
separator: Some(&Node::Punct(',')),
|
||
min: 1,
|
||
};
|
||
const AR_PARENT_COLS_PAREN_NODES: &[Node] =
|
||
&[Node::Punct('('), AR_PARENT_COL_LIST, Node::Punct(')')];
|
||
const AR_PARENT_COLS_PAREN: Node = Node::Seq(AR_PARENT_COLS_PAREN_NODES);
|
||
// `from P.(a, b)` (compound) or `from P.col` (single) — Choice on
|
||
// the first post-`.` token (`(` vs an ident), so order is safe.
|
||
const AR_PARENT_COLS_CHOICES: &[Node] = &[AR_PARENT_COLS_PAREN, AR_PARENT_COL];
|
||
const AR_PARENT_COLS: Node = Node::Choice(AR_PARENT_COLS_CHOICES);
|
||
|
||
const AR_PARENT_NODES: &[Node] = &[
|
||
Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "parent_table",
|
||
validator: None,
|
||
highlight_override: None,
|
||
writes_table: true,
|
||
writes_column: false,
|
||
writes_user_listed_column: false,
|
||
writes_table_alias: false,
|
||
writes_cte_name: false,
|
||
writes_projection_alias: false,
|
||
},
|
||
Node::Punct('.'),
|
||
AR_PARENT_COLS,
|
||
];
|
||
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
||
|
||
const AR_CHILD_COL: Node = Node::Ident {
|
||
source: IdentSource::Columns,
|
||
role: "child_column",
|
||
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,
|
||
};
|
||
const AR_CHILD_COL_LIST: Node = Node::Repeated {
|
||
inner: &AR_CHILD_COL,
|
||
separator: Some(&Node::Punct(',')),
|
||
min: 1,
|
||
};
|
||
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
|
||
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
|
||
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
|
||
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
|
||
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
|
||
|
||
const AR_CHILD_NODES: &[Node] = &[
|
||
Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "child_table",
|
||
validator: None,
|
||
highlight_override: None,
|
||
writes_table: true,
|
||
writes_column: false,
|
||
writes_user_listed_column: false,
|
||
writes_table_alias: false,
|
||
writes_cte_name: false,
|
||
writes_projection_alias: false,
|
||
},
|
||
Node::Punct('.'),
|
||
AR_CHILD_COLS,
|
||
];
|
||
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
||
|
||
const AR_AS_NAME_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("as")),
|
||
RELATIONSHIP_NAME_NEW,
|
||
];
|
||
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
|
||
|
||
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
|
||
|
||
const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
||
Node::Literal("1"),
|
||
Node::Punct(':'),
|
||
Node::Word(Word::keyword("n")),
|
||
Node::Word(Word::keyword("relationship")),
|
||
AR_AS_NAME_OPT,
|
||
Node::Word(Word::keyword("from")),
|
||
AR_PARENT,
|
||
Node::Word(Word::keyword("to")),
|
||
AR_CHILD,
|
||
REFERENTIAL_CLAUSES,
|
||
AR_CREATE_FK_OPT,
|
||
];
|
||
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
|
||
|
||
// =================================================================
|
||
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
||
// =================================================================
|
||
|
||
const AI_AS_NAME_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("as")),
|
||
INDEX_NAME_NEW,
|
||
];
|
||
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
|
||
|
||
const ADD_INDEX_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("index")),
|
||
AI_AS_NAME_OPT,
|
||
Node::Word(Word::keyword("on")),
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct('('),
|
||
INDEX_COLUMN_LIST,
|
||
Node::Punct(')'),
|
||
];
|
||
const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);
|
||
|
||
// =================================================================
|
||
// add entry — `add (column|1:n relationship|index) …`
|
||
// =================================================================
|
||
|
||
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX, ADD_CONSTRAINT];
|
||
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
|
||
|
||
// =================================================================
|
||
// rename_column — `rename column [in] [table] <T> : <col> to <new>`
|
||
// =================================================================
|
||
|
||
const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "new_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,
|
||
};
|
||
const NEW_COLUMN_NAME: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &NEW_COLUMN_NAME_IDENT,
|
||
};
|
||
|
||
const RENAME_COLUMN_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
IN_OPT,
|
||
TABLE_OPT,
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct(':'),
|
||
COLUMN_NAME,
|
||
Node::Word(Word::keyword("to")),
|
||
NEW_COLUMN_NAME,
|
||
];
|
||
const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
|
||
|
||
// =================================================================
|
||
// change_column — `change column [in] [table] <T> : <col>
|
||
// ( <type> ) [--force-conversion | --dont-convert]`
|
||
// =================================================================
|
||
|
||
const CHANGE_FLAG_CHOICES: &[Node] = &[
|
||
Node::Flag("force-conversion"),
|
||
Node::Flag("dont-convert"),
|
||
];
|
||
const CHANGE_FLAG_OPT: Node = Node::Repeated {
|
||
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
|
||
separator: None,
|
||
min: 0,
|
||
};
|
||
|
||
const CHANGE_COLUMN_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
IN_OPT,
|
||
TABLE_OPT,
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct(':'),
|
||
COLUMN_NAME,
|
||
Node::Punct('('),
|
||
TYPE_SLOT,
|
||
Node::Punct(')'),
|
||
CHANGE_FLAG_OPT,
|
||
];
|
||
const CHANGE_COLUMN: Node = Node::Seq(CHANGE_COLUMN_NODES);
|
||
|
||
// =================================================================
|
||
// AST builders
|
||
// =================================================================
|
||
|
||
/// First ident whose role matches.
|
||
fn ident<'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<String, ValidationError> {
|
||
ident(path, role)
|
||
.map(str::to_string)
|
||
.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", format!("missing {role}"))],
|
||
})
|
||
}
|
||
|
||
/// Every ident whose role matches, in matched (left-to-right)
|
||
/// order. Used by the column-list commands.
|
||
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
|
||
path.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.clone()),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn parse_action(words: &[&'static str]) -> ReferentialAction {
|
||
// `set null`, `no action`, `cascade`, `restrict`.
|
||
if words.contains(&"set") && words.contains(&"null") {
|
||
ReferentialAction::SetNull
|
||
} else if words.contains(&"no") && words.contains(&"action") {
|
||
ReferentialAction::NoAction
|
||
} else if words.contains(&"cascade") {
|
||
ReferentialAction::Cascade
|
||
} else if words.contains(&"restrict") {
|
||
ReferentialAction::Restrict
|
||
} else {
|
||
ReferentialAction::default_action()
|
||
}
|
||
}
|
||
|
||
fn build_drop(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
// Discriminate by the second word matched (the entry was
|
||
// `drop`, the next Word is `table` / `column` / `relationship`).
|
||
let sub = path
|
||
.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Word(w) => Some(*w),
|
||
_ => None,
|
||
})
|
||
.nth(1);
|
||
match sub {
|
||
Some("table") => Ok(Command::DropTable {
|
||
name: require_ident(path, "table_name")?,
|
||
}),
|
||
Some("column") => Ok(Command::DropColumn {
|
||
table: require_ident(path, "table_name")?,
|
||
column: require_ident(path, "column_name")?,
|
||
cascade: path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))),
|
||
}),
|
||
Some("index") => {
|
||
// Positional form has `on` as the third Word.
|
||
let has_on = path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(&i.kind, MatchedKind::Word("on")));
|
||
if has_on {
|
||
Ok(Command::DropIndex {
|
||
selector: IndexSelector::Columns {
|
||
table: require_ident(path, "table_name")?,
|
||
columns: collect_idents(path, "column_name"),
|
||
},
|
||
})
|
||
} else {
|
||
Ok(Command::DropIndex {
|
||
selector: IndexSelector::Named {
|
||
name: require_ident(path, "index_name")?,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
Some("constraint") => build_drop_constraint(path, _source),
|
||
Some("relationship") => {
|
||
// Endpoints form has `from` as the third Word.
|
||
let has_from = path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(&i.kind, MatchedKind::Word("from")));
|
||
if has_from {
|
||
Ok(Command::DropRelationship {
|
||
selector: RelationshipSelector::Endpoints {
|
||
parent_table: require_ident(path, "parent_table")?,
|
||
parent_column: require_ident(path, "parent_column")?,
|
||
child_table: require_ident(path, "child_table")?,
|
||
child_column: require_ident(path, "child_column")?,
|
||
},
|
||
})
|
||
} else {
|
||
Ok(Command::DropRelationship {
|
||
selector: RelationshipSelector::Named {
|
||
name: require_ident(path, "relationship_name")?,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
_ => Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown drop subcommand".to_string())],
|
||
}),
|
||
}
|
||
}
|
||
|
||
fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
// Second matched Word distinguishes column vs the `1:n
|
||
// relationship` form. The `1` literal counts as a Word
|
||
// (the walker records Literal matches as MatchedKind::Word
|
||
// for AST-builder uniformity).
|
||
let second_word = path
|
||
.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Word(w) => Some(*w),
|
||
_ => None,
|
||
})
|
||
.nth(1);
|
||
match second_word {
|
||
Some("column") => {
|
||
let ty_text = require_ident(path, "type")?;
|
||
let ty = ty_text
|
||
.parse::<crate::dsl::types::Type>()
|
||
.map_err(|_| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?;
|
||
let (not_null, unique, default, check) =
|
||
collect_column_constraints(path)?;
|
||
Ok(Command::AddColumn {
|
||
table: require_ident(path, "table_name")?,
|
||
column: require_ident(path, "column_name")?,
|
||
ty,
|
||
not_null,
|
||
unique,
|
||
default,
|
||
check,
|
||
})
|
||
}
|
||
Some("1") => build_add_relationship(path, _source),
|
||
Some("index") => Ok(Command::AddIndex {
|
||
name: ident(path, "index_name").map(str::to_string),
|
||
table: require_ident(path, "table_name")?,
|
||
columns: collect_idents(path, "column_name"),
|
||
}),
|
||
Some("constraint") => build_add_constraint(path, _source),
|
||
_ => Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown add subcommand".to_string())],
|
||
}),
|
||
}
|
||
}
|
||
|
||
fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
// Collect all referential-clause actions in matched order
|
||
// and validate at-most-2 + not-repeated. The `on <delete|
|
||
// update> <action>` sequence shows up as a run of Word
|
||
// matches in the path between `to <child>` and end.
|
||
//
|
||
// Strategy: walk through Word items in order; whenever we
|
||
// see `on`, the next Word is the target, then the action
|
||
// word(s) follow until the next `on`, `--create-fk`, or
|
||
// end-of-path.
|
||
let words: Vec<&'static str> = path
|
||
.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Word(w) => Some(*w),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
let mut on_delete: Option<ReferentialAction> = None;
|
||
let mut on_update: Option<ReferentialAction> = None;
|
||
let mut i = 0;
|
||
while i < words.len() {
|
||
if words[i] == "on" && i + 1 < words.len() {
|
||
let target = words[i + 1];
|
||
// Action runs from i+2 until the next `on` or end.
|
||
let action_start = i + 2;
|
||
let mut action_end = action_start;
|
||
while action_end < words.len() && words[action_end] != "on" {
|
||
action_end += 1;
|
||
}
|
||
let action = parse_action(&words[action_start..action_end]);
|
||
let slot = match target {
|
||
"delete" => &mut on_delete,
|
||
"update" => &mut on_update,
|
||
_ => {
|
||
i = action_end;
|
||
continue;
|
||
}
|
||
};
|
||
if slot.is_some() {
|
||
return Err(ValidationError {
|
||
message_key: "parse.custom.on_action_specified_twice",
|
||
args: vec![("target", target.to_string())],
|
||
});
|
||
}
|
||
*slot = Some(action);
|
||
i = action_end;
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
let create_fk = path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
|
||
|
||
// Collect every matched `parent_column` / `child_column` ident, in
|
||
// order — one each for the single-column `from P.col to C.col`
|
||
// form, or the full lists for the parenthesized compound form
|
||
// `from P.(a, b) to C.(x, y)` (ADR-0043).
|
||
let parent_columns = collect_idents(path, "parent_column");
|
||
let child_columns = collect_idents(path, "child_column");
|
||
if parent_columns.is_empty() || child_columns.is_empty() {
|
||
return Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "a relationship needs both endpoints".to_string())],
|
||
});
|
||
}
|
||
Ok(Command::AddRelationship {
|
||
name: ident(path, "relationship_name").map(str::to_string),
|
||
parent_table: require_ident(path, "parent_table")?,
|
||
parent_columns,
|
||
child_table: require_ident(path, "child_table")?,
|
||
child_columns,
|
||
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
|
||
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
|
||
create_fk,
|
||
})
|
||
}
|
||
|
||
fn build_rename_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
Ok(Command::RenameColumn {
|
||
table: require_ident(path, "table_name")?,
|
||
old: require_ident(path, "column_name")?,
|
||
new: require_ident(path, "new_column_name")?,
|
||
})
|
||
}
|
||
|
||
fn build_change_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
let ty_text = require_ident(path, "type")?;
|
||
let ty = ty_text
|
||
.parse::<crate::dsl::types::Type>()
|
||
.map_err(|_| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?;
|
||
|
||
// Flags: at most one of --force-conversion / --dont-convert.
|
||
let flags: Vec<&'static str> = path
|
||
.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Flag(n) => Some(*n),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let mode = match flags.as_slice() {
|
||
[] => ChangeColumnMode::Default,
|
||
[one] => match *one {
|
||
"force-conversion" => ChangeColumnMode::ForceConversion,
|
||
"dont-convert" => ChangeColumnMode::DontConvert,
|
||
_ => ChangeColumnMode::Default,
|
||
},
|
||
_ => {
|
||
// Two or more flags — mutual exclusion fires
|
||
// whether they're the same flag twice or both
|
||
// mutually-exclusive flags appear. Wording mirrors
|
||
// the chumsky parser's `change_column_flags_exclusive`.
|
||
return Err(ValidationError {
|
||
message_key: "parse.custom.change_column_flags_exclusive",
|
||
args: vec![],
|
||
});
|
||
}
|
||
};
|
||
|
||
Ok(Command::ChangeColumnType {
|
||
table: require_ident(path, "table_name")?,
|
||
column: require_ident(path, "column_name")?,
|
||
ty,
|
||
mode,
|
||
})
|
||
}
|
||
|
||
/// Build an `add constraint <constraint> to <T>.<col>` command
|
||
/// (ADR-0029 §2.2). The `<constraint>` reuses the §2.1
|
||
/// `COLUMN_CONSTRAINT` Choice, so exactly one of the four
|
||
/// constraint kinds is matched; `collect_column_constraints`
|
||
/// recovers it. The §9 redundancy and §5 dry-run checks are
|
||
/// execution-time (the parser has no schema) and live in the
|
||
/// database worker.
|
||
fn build_add_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
let (not_null, unique, default, check) = collect_column_constraints(path)?;
|
||
let constraint = if not_null {
|
||
Constraint::NotNull
|
||
} else if unique {
|
||
Constraint::Unique
|
||
} else if let Some(value) = default {
|
||
Constraint::Default(value)
|
||
} else if let Some(expr) = check {
|
||
Constraint::Check(expr)
|
||
} else {
|
||
return Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "add constraint needs a constraint".to_string())],
|
||
});
|
||
};
|
||
Ok(Command::AddConstraint {
|
||
table: require_ident(path, "table_name")?,
|
||
column: require_ident(path, "column_name")?,
|
||
constraint,
|
||
})
|
||
}
|
||
|
||
/// Build a `drop constraint <kind> from <T>.<col>` command
|
||
/// (ADR-0029 §2.2). `drop` names only the kind — the
|
||
/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind
|
||
/// is recovered from which keyword(s) the path matched.
|
||
fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
let words: Vec<&'static str> = path
|
||
.items
|
||
.iter()
|
||
.filter_map(|i| match &i.kind {
|
||
MatchedKind::Word(w) => Some(*w),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// `not` appears only in the `not null` Seq, so its presence
|
||
// alone identifies the kind.
|
||
let kind = if words.contains(&"not") {
|
||
ConstraintKind::NotNull
|
||
} else if words.contains(&"unique") {
|
||
ConstraintKind::Unique
|
||
} else if words.contains(&"default") {
|
||
ConstraintKind::Default
|
||
} else if words.contains(&"check") {
|
||
ConstraintKind::Check
|
||
} else {
|
||
return Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
|
||
});
|
||
};
|
||
Ok(Command::DropConstraint {
|
||
table: require_ident(path, "table_name")?,
|
||
column: require_ident(path, "column_name")?,
|
||
kind,
|
||
})
|
||
}
|
||
|
||
// =================================================================
|
||
// CommandNodes
|
||
// =================================================================
|
||
|
||
pub static DROP: CommandNode = CommandNode {
|
||
entry: Word::keyword("drop"),
|
||
shape: DROP_SHAPE,
|
||
ast_builder: build_drop,
|
||
help_id: Some("ddl.drop"),
|
||
hint_ids: &[],
|
||
usage_ids: &[
|
||
"parse.usage.drop_table",
|
||
"parse.usage.drop_column",
|
||
"parse.usage.drop_relationship",
|
||
"parse.usage.drop_index",
|
||
"parse.usage.drop_constraint",
|
||
],};
|
||
|
||
pub static ADD: CommandNode = CommandNode {
|
||
entry: Word::keyword("add"),
|
||
shape: ADD_SHAPE,
|
||
ast_builder: build_add,
|
||
help_id: Some("ddl.add"),
|
||
// Per-form (ADR-0053 D3): every form is listed so the form-word
|
||
// disambiguation resolves correctly; forms without an authored
|
||
// block yet fall back to tier-2 at render. `add_relationship` is
|
||
// authored as a Phase-B exemplar.
|
||
hint_ids: &[
|
||
"add_column",
|
||
"add_relationship",
|
||
"add_index",
|
||
"add_constraint",
|
||
],
|
||
usage_ids: &[
|
||
"parse.usage.add_column",
|
||
"parse.usage.add_relationship",
|
||
"parse.usage.add_index",
|
||
"parse.usage.add_constraint",
|
||
],};
|
||
|
||
pub static RENAME: CommandNode = CommandNode {
|
||
entry: Word::keyword("rename"),
|
||
shape: RENAME_COLUMN,
|
||
ast_builder: build_rename_column,
|
||
help_id: Some("ddl.rename"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.rename_column"],};
|
||
|
||
pub static CHANGE: CommandNode = CommandNode {
|
||
entry: Word::keyword("change"),
|
||
shape: CHANGE_COLUMN,
|
||
ast_builder: build_change_column,
|
||
help_id: Some("ddl.change"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.change_column"],};
|
||
|
||
// =================================================================
|
||
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
|
||
// (Phase C)
|
||
// =================================================================
|
||
|
||
const COL_NAME_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "col_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,
|
||
};
|
||
const COL_NAME: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &COL_NAME_IDENT,
|
||
};
|
||
|
||
// ADR-0029 column-constraint suffix — `not null`, `unique`,
|
||
// `default <literal>`, `check (<expr>)`. One shared fragment:
|
||
// `create table` uses it here, `add column` reuses it as its
|
||
// type suffix, and `add constraint` reuses the individual
|
||
// `COLUMN_CONSTRAINT` Choice for its constraint slot.
|
||
const NOT_NULL_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("not")),
|
||
Node::Word(Word::keyword("null")),
|
||
];
|
||
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
|
||
|
||
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique"));
|
||
|
||
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("default")),
|
||
super::shared::FALLBACK_VALUE_LITERAL,
|
||
];
|
||
const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES);
|
||
|
||
// `check ( <expr> )` — the expression is the ADR-0026 WHERE
|
||
// grammar, reached through `Subgrammar` (ADR-0029 §2.1). The
|
||
// parentheses match SQL's `CHECK (…)` and give the parser an
|
||
// unambiguous end for the expression.
|
||
const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("check")),
|
||
Node::Punct('('),
|
||
Node::Subgrammar(&super::expr::OR_EXPR),
|
||
Node::Punct(')'),
|
||
];
|
||
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
|
||
|
||
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
|
||
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
|
||
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
|
||
|
||
/// Zero-or-more constraints — the suffix after a column's
|
||
/// `(type)` group (ADR-0029 §2.1). `min: 0` so an
|
||
/// unconstrained column still matches.
|
||
const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||
inner: &COLUMN_CONSTRAINT,
|
||
separator: None,
|
||
min: 0,
|
||
};
|
||
|
||
// =================================================================
|
||
// add_constraint / drop_constraint — `add constraint <constraint>
|
||
// to <T>.<col>` / `drop constraint <kind> from <T>.<col>`
|
||
// (ADR-0029 §2.2)
|
||
// =================================================================
|
||
|
||
// Payload-free keyword nodes for `drop constraint` — naming the
|
||
// kind is enough, since at most one constraint of each kind
|
||
// exists per column. `not null` / `unique` reuse the §2.1
|
||
// keyword-only nodes; `default` / `check` need bare-keyword
|
||
// variants here (their §2.1 forms carry a literal / expression
|
||
// payload that `drop` does not take).
|
||
const DROP_DEFAULT_KEYWORD: Node = Node::Word(Word::keyword("default"));
|
||
const DROP_CHECK_KEYWORD: Node = Node::Word(Word::keyword("check"));
|
||
const DROP_CONSTRAINT_KIND_CHOICES: &[Node] = &[
|
||
NOT_NULL_CONSTRAINT,
|
||
UNIQUE_CONSTRAINT,
|
||
DROP_DEFAULT_KEYWORD,
|
||
DROP_CHECK_KEYWORD,
|
||
];
|
||
const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
|
||
|
||
// The dotted `<Table>.<column>` target — the same `Ident '.'
|
||
// Ident` shape `add 1:n relationship` uses for its endpoints.
|
||
// `writes_table: true` on the table ident (via `TABLE_NAME_
|
||
// EXISTING`) narrows the `.<column>` slot's completion
|
||
// candidates to that table's columns.
|
||
const CONSTRAINT_TARGET_NODES: &[Node] =
|
||
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
|
||
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
|
||
|
||
const ADD_CONSTRAINT_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("constraint")),
|
||
COLUMN_CONSTRAINT,
|
||
Node::Word(Word::keyword("to")),
|
||
CONSTRAINT_TARGET,
|
||
];
|
||
const ADD_CONSTRAINT: Node = Node::Seq(ADD_CONSTRAINT_NODES);
|
||
|
||
const DROP_CONSTRAINT_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("constraint")),
|
||
DROP_CONSTRAINT_KIND,
|
||
Node::Word(Word::keyword("from")),
|
||
CONSTRAINT_TARGET,
|
||
];
|
||
const DROP_CONSTRAINT: Node = Node::Seq(DROP_CONSTRAINT_NODES);
|
||
|
||
const COL_SPEC_NODES: &[Node] = &[
|
||
COL_NAME,
|
||
Node::Punct('('),
|
||
Node::Ident {
|
||
source: IdentSource::Types,
|
||
role: "col_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,
|
||
},
|
||
Node::Punct(')'),
|
||
COLUMN_CONSTRAINT_SUFFIX,
|
||
];
|
||
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);
|
||
|
||
const SPEC_LIST: Node = Node::Repeated {
|
||
inner: &COL_SPEC,
|
||
separator: Some(&Node::Punct(',')),
|
||
min: 1,
|
||
};
|
||
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
|
||
|
||
const WITH_PK_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("with")),
|
||
Node::Word(Word::keyword("pk")),
|
||
SPEC_LIST_OPT,
|
||
];
|
||
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
|
||
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
||
|
||
const CREATE_TABLE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("table")),
|
||
TABLE_NAME_NEW,
|
||
WITH_PK_OPT,
|
||
];
|
||
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
|
||
|
||
/// Consume a `check` constraint's `( <expr> )` from `items`,
|
||
/// which must be positioned just after the `Word("check")`,
|
||
/// and build the ADR-0026 expression (ADR-0029 §2.1). The
|
||
/// grammar's `Seq` guarantees the surrounding `(` … `)`;
|
||
/// paren depth handles a parenthesised sub-expression inside.
|
||
fn consume_check_expr(
|
||
items: &mut std::iter::Peekable<std::slice::Iter<'_, MatchedItem>>,
|
||
) -> Result<Expr, ValidationError> {
|
||
items.next(); // the opening `(`
|
||
let mut depth = 1usize;
|
||
let mut expr_items: Vec<MatchedItem> = Vec::new();
|
||
for inner in items.by_ref() {
|
||
match &inner.kind {
|
||
MatchedKind::Punct('(') => {
|
||
depth += 1;
|
||
expr_items.push(inner.clone());
|
||
}
|
||
MatchedKind::Punct(')') => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
break;
|
||
}
|
||
expr_items.push(inner.clone());
|
||
}
|
||
_ => expr_items.push(inner.clone()),
|
||
}
|
||
}
|
||
super::expr::build_expr(&expr_items)
|
||
}
|
||
|
||
/// Collect the ADR-0029 constraint suffix from a
|
||
/// single-column command's matched path (`add column`),
|
||
/// returning the `(not_null, unique, default, check)` tuple.
|
||
/// The scan reacts only to the constraint keywords, so
|
||
/// passing the whole path is safe. (`create table`'s
|
||
/// multi-column collection is inline in `build_create_table`.)
|
||
fn collect_column_constraints(
|
||
path: &MatchedPath,
|
||
) -> Result<(bool, bool, Option<Value>, Option<Expr>), ValidationError> {
|
||
let mut not_null = false;
|
||
let mut unique = false;
|
||
let mut default = None;
|
||
let mut check = None;
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
match &item.kind {
|
||
MatchedKind::Word("not") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("null"))
|
||
) {
|
||
items.next();
|
||
not_null = true;
|
||
}
|
||
}
|
||
MatchedKind::Word("unique") => unique = true,
|
||
MatchedKind::Word("default") => {
|
||
let value = items
|
||
.next()
|
||
.and_then(crate::dsl::grammar::data::item_to_value)
|
||
.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "default needs a value".to_string())],
|
||
})?;
|
||
default = Some(value);
|
||
}
|
||
MatchedKind::Word("check") => {
|
||
check = Some(consume_check_expr(&mut items)?);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Ok((not_null, unique, default, check))
|
||
}
|
||
|
||
/// The friendly error for declaring a constraint a
|
||
/// primary-key column already implies (ADR-0029 §9).
|
||
fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError {
|
||
ValidationError {
|
||
message_key: "parse.custom.constraint_redundant_on_pk",
|
||
args: vec![
|
||
("column", column.to_string()),
|
||
("constraint", constraint.to_string()),
|
||
],
|
||
}
|
||
}
|
||
|
||
fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
let name = require_ident(path, "table_name")?;
|
||
|
||
// Walk the matched items, segmenting per column: a
|
||
// `col_name` ident stashes the name, the following
|
||
// `col_type` ident finalises the spec, and the constraint
|
||
// tokens after it (ADR-0029 §2.1) attach to that spec.
|
||
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||
let mut pending_name: Option<String> = None;
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
match &item.kind {
|
||
MatchedKind::Ident { role: "col_name", .. } => {
|
||
pending_name = Some(item.text.clone());
|
||
}
|
||
MatchedKind::Ident { role: "col_type", .. } => {
|
||
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?;
|
||
let col_name = pending_name.take().ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "column type without a name".to_string())],
|
||
})?;
|
||
columns.push(ColumnSpec::new(col_name, ty));
|
||
}
|
||
// `not null` — the grammar's `Seq` guarantees a
|
||
// `null` Word follows a matched `not` Word.
|
||
MatchedKind::Word("not") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("null"))
|
||
) {
|
||
items.next();
|
||
if let Some(last) = columns.last_mut() {
|
||
last.not_null = true;
|
||
}
|
||
}
|
||
}
|
||
MatchedKind::Word("unique") => {
|
||
if let Some(last) = columns.last_mut() {
|
||
last.unique = true;
|
||
}
|
||
}
|
||
// `default <literal>` — the `Seq` guarantees a value
|
||
// item follows a matched `default` Word.
|
||
MatchedKind::Word("default") => {
|
||
let value = items
|
||
.next()
|
||
.and_then(crate::dsl::grammar::data::item_to_value)
|
||
.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "default needs a value".to_string())],
|
||
})?;
|
||
if let Some(last) = columns.last_mut() {
|
||
last.default = Some(value);
|
||
}
|
||
}
|
||
// `check ( <expr> )` (ADR-0029 §2.1).
|
||
MatchedKind::Word("check") => {
|
||
let expr = consume_check_expr(&mut items)?;
|
||
if let Some(last) = columns.last_mut() {
|
||
last.check = Some(expr);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// No PK clause OR `with pk` alone (no specs): if `with` was
|
||
// matched, default to id(serial); otherwise reject with the
|
||
// "tables need a primary key" friendly wording.
|
||
if columns.is_empty() {
|
||
let saw_with = path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(i.kind, MatchedKind::Word("with")));
|
||
if saw_with {
|
||
columns.push(ColumnSpec::new("id", Type::Serial));
|
||
} else {
|
||
return Err(ValidationError {
|
||
message_key: "parse.custom.create_table_needs_pk",
|
||
args: vec![],
|
||
});
|
||
}
|
||
}
|
||
|
||
// Every `with pk` column is part of the primary key
|
||
// (ADR-0029 §2.1). A PK column is already NOT NULL, and a
|
||
// single-column PK is already UNIQUE — declaring those
|
||
// explicitly is a friendly error, not a silent no-op
|
||
// (ADR-0029 §9).
|
||
let single_column_pk = columns.len() == 1;
|
||
for col in &columns {
|
||
if col.not_null {
|
||
return Err(redundant_pk_constraint(&col.name, "NOT NULL"));
|
||
}
|
||
if col.unique && single_column_pk {
|
||
return Err(redundant_pk_constraint(&col.name, "UNIQUE"));
|
||
}
|
||
}
|
||
|
||
let primary_key = columns.iter().map(|c| c.name.clone()).collect();
|
||
|
||
Ok(Command::CreateTable {
|
||
name,
|
||
columns,
|
||
primary_key,
|
||
})
|
||
}
|
||
|
||
pub static CREATE: CommandNode = CommandNode {
|
||
entry: Word::keyword("create"),
|
||
shape: CREATE_TABLE,
|
||
ast_builder: build_create_table,
|
||
help_id: Some("ddl.create"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.create_table"],};
|
||
|
||
// =================================================================
|
||
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
|
||
// + two 1:n relationships. A *separate* `CommandNode` under the shared
|
||
// `create` entry word (the walker dispatches both); the `m` opener is a
|
||
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
|
||
// the `1` in `add 1:n relationship`.
|
||
// =================================================================
|
||
|
||
const M2N_T1: Node = Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "m2n_t1",
|
||
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,
|
||
};
|
||
const M2N_T2: Node = Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "m2n_t2",
|
||
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,
|
||
};
|
||
// Optional `as <junction name>` — a *new* table name (the junction),
|
||
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
|
||
// hint). The only `table_name` role in this path, so the builder reads
|
||
// it directly as the junction name.
|
||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||
|
||
const CREATE_M2N_NODES: &[Node] = &[
|
||
Node::Literal("m"),
|
||
Node::Punct(':'),
|
||
Node::Word(Word::keyword("n")),
|
||
Node::Word(Word::keyword("relationship")),
|
||
Node::Word(Word::keyword("from")),
|
||
M2N_T1,
|
||
Node::Word(Word::keyword("to")),
|
||
M2N_T2,
|
||
M2N_AS_NAME_OPT,
|
||
];
|
||
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
|
||
|
||
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
Ok(Command::CreateM2nRelationship {
|
||
t1: require_ident(path, "m2n_t1")?,
|
||
t2: require_ident(path, "m2n_t2")?,
|
||
name: ident(path, "table_name").map(str::to_string),
|
||
})
|
||
}
|
||
|
||
pub static CREATE_M2N: CommandNode = CommandNode {
|
||
entry: Word::keyword("create"),
|
||
shape: CREATE_M2N_SHAPE,
|
||
ast_builder: build_create_m2n,
|
||
help_id: Some("ddl.create_m2n"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.create_m2n"],
|
||
};
|
||
|
||
/// The friendly error for a column type without a preceding name —
|
||
/// a structural impossibility given the grammar, defended anyway.
|
||
fn sql_col_type_without_name() -> ValidationError {
|
||
ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "column type without a name".to_string())],
|
||
}
|
||
}
|
||
|
||
/// Build a `Command::SqlCreateTable` from the advanced-mode SQL
|
||
/// `CREATE TABLE` shape (ADR-0035 §1, sub-phases 4a + 4a.2). Executes
|
||
/// structurally — extracts the same `ColumnSpec`/`primary_key` the
|
||
/// simple-mode builder produces so the worker reuses `do_create_table`.
|
||
///
|
||
/// Surface: columns and types (the §3 alias map incl. `double
|
||
/// precision`), `NOT NULL` / `UNIQUE` / column- and table-level
|
||
/// `PRIMARY KEY`, and `IF NOT EXISTS` (4a); per-column `DEFAULT` and
|
||
/// `CHECK` (raw `sql_expr` text captured by byte span — `sql_expr`
|
||
/// builds no AST) and composite `UNIQUE (a, b)` (4a.2). Table-level
|
||
/// multi-column `CHECK` and FK are absent from the grammar (4a.3 / 4b).
|
||
fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||
let name = require_ident(path, "table_name")?;
|
||
// `if` only appears in the `IF NOT EXISTS` prefix (the `not` of
|
||
// `NOT NULL` never carries an `if`), so its presence is the flag.
|
||
let if_not_exists = path
|
||
.items
|
||
.iter()
|
||
.any(|i| matches!(i.kind, MatchedKind::Word("if")));
|
||
|
||
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||
let mut primary_key: Vec<String> = Vec::new();
|
||
let mut unique_constraints: Vec<Vec<String>> = Vec::new();
|
||
let mut check_constraints: Vec<String> = Vec::new();
|
||
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
|
||
let mut pending_name: Option<String> = None;
|
||
// Distinguish a table-level `CHECK (…)` from a column-level one
|
||
// (ADR-0035 §4a.3): both are spelled `check (`, and `Word` matches
|
||
// carry no role, so position is the only signal. `column_open` is
|
||
// `true` while a column definition is accepting constraints in the
|
||
// current element; a `check` seen while it is `false` is table-level.
|
||
// `depth` tracks the parens that reach this loop (the outer column
|
||
// list, type length-args `(10, 2)`, and table-`PRIMARY KEY (a, b)` —
|
||
// the `check`/`default`/table-`unique` arms consume their own parens
|
||
// internally, so they never perturb it). An element separator is a
|
||
// comma at the column-list interior, `depth == 1`.
|
||
let mut column_open = false;
|
||
let mut depth = 0usize;
|
||
// A `CONSTRAINT <name>` prefix stashes the name until the following
|
||
// table-level `FOREIGN KEY` consumes it (ADR-0035 §5, 4b).
|
||
let mut pending_fk_name: Option<String> = None;
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
match &item.kind {
|
||
// A column name stashes until its type finalises the spec.
|
||
MatchedKind::Ident { role: "col_name", .. } => {
|
||
pending_name = Some(item.text.clone());
|
||
}
|
||
// Single-word type — resolve through the SQL alias map.
|
||
MatchedKind::Ident { role: "col_type", .. } => {
|
||
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?;
|
||
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||
columns.push(ColumnSpec::new(col_name, ty));
|
||
column_open = true;
|
||
}
|
||
// `double precision` — the two-word alias maps to `real`.
|
||
// The grammar guarantees `precision` follows `double`.
|
||
MatchedKind::Word("double") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("precision"))
|
||
) {
|
||
items.next();
|
||
}
|
||
let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||
columns.push(ColumnSpec::new(col_name, Type::Real));
|
||
column_open = true;
|
||
}
|
||
// A table-level `PRIMARY KEY (col, …)` column reference.
|
||
MatchedKind::Ident { role: "pk_column", .. } => {
|
||
primary_key.push(item.text.clone());
|
||
}
|
||
// `not null` column constraint (only once a column exists;
|
||
// the `IF NOT EXISTS` `not` precedes every column).
|
||
MatchedKind::Word("not") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("null"))
|
||
) {
|
||
items.next();
|
||
if let Some(last) = columns.last_mut() {
|
||
last.not_null = true;
|
||
}
|
||
}
|
||
}
|
||
// `unique` — table-level `UNIQUE (cols)` when followed by
|
||
// `(`, else a column-level constraint on the last column.
|
||
MatchedKind::Word("unique") => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||
items.next(); // consume '('
|
||
let mut cols: Vec<String> = Vec::new();
|
||
while let Some(it) = items.peek() {
|
||
match &it.kind {
|
||
MatchedKind::Ident { role: "unique_column", .. } => {
|
||
cols.push(it.text.clone());
|
||
items.next();
|
||
}
|
||
MatchedKind::Punct(',') => {
|
||
items.next();
|
||
}
|
||
MatchedKind::Punct(')') => {
|
||
items.next();
|
||
break;
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
// Single-column table-level UNIQUE folds into the
|
||
// column's flag (round-trips via the single-column
|
||
// path); composite (or a name not among the
|
||
// columns) becomes a constraint.
|
||
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
|
||
Some(c) => c.unique = true,
|
||
None if !cols.is_empty() => unique_constraints.push(cols),
|
||
None => {}
|
||
}
|
||
} else if let Some(last) = columns.last_mut() {
|
||
last.unique = true;
|
||
}
|
||
}
|
||
// `primary key` — either a column-level constraint (mark
|
||
// the most recent column) or the table-level clause (whose
|
||
// `pk_column` idents follow and are collected above).
|
||
MatchedKind::Word("primary") => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||
items.next();
|
||
// Table-level `PRIMARY KEY (…)` is followed by `(`
|
||
// (then `pk_column` idents, collected above);
|
||
// column-level `PRIMARY KEY` is not, and marks the
|
||
// most-recent column.
|
||
let table_level = matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Punct('('))
|
||
);
|
||
if !table_level && let Some(last) = columns.last() {
|
||
primary_key.push(last.name.clone());
|
||
}
|
||
}
|
||
}
|
||
// `default <expr>` — capture the expression's raw SQL text
|
||
// by byte span (`sql_expr` builds no AST). The match is
|
||
// maximal, so the expression runs until a depth-0 element
|
||
// boundary (`,` / `)`) or the next constraint keyword.
|
||
MatchedKind::Word("default") => {
|
||
if let Some((s, e)) = capture_expr_span(&mut items)
|
||
&& let Some(last) = columns.last_mut()
|
||
{
|
||
last.default_sql = Some(source[s..e].trim().to_string());
|
||
}
|
||
}
|
||
// `check ( <expr> )` — capture the inner expression text
|
||
// (without the wrapping parens) by matching paren depth, then
|
||
// route by element position: a CHECK inside an open column
|
||
// definition is column-level (4a.2); one seen at element
|
||
// start (no column open) is a table-level CHECK (4a.3).
|
||
MatchedKind::Word("check") => {
|
||
if let Some((s, e)) = capture_parenthesised_span(&mut items) {
|
||
let text = source[s..e].trim().to_string();
|
||
if column_open {
|
||
if let Some(last) = columns.last_mut() {
|
||
last.check_sql = Some(text);
|
||
}
|
||
} else {
|
||
check_constraints.push(text);
|
||
}
|
||
}
|
||
}
|
||
// `constraint <name>` — stash the name for the table-level
|
||
// `foreign key` that follows (ADR-0035 §5, 4b).
|
||
MatchedKind::Word("constraint") => {
|
||
if let Some(it) = items.next() {
|
||
pending_fk_name = Some(it.text.clone());
|
||
}
|
||
}
|
||
// Inline `references <parent> [(<col>)] [on …]` — a
|
||
// column-level FK on the current column (ADR-0035 §5, 4b).
|
||
// Auto-named at execution; the FK clause's own parens are
|
||
// consumed in `consume_fk_reference`, so they don't perturb
|
||
// the element-boundary `depth` tracker.
|
||
MatchedKind::Word("references") => {
|
||
// Inline FK is single-column (the column it sits on);
|
||
// a compound FK uses the table-level form (ADR-0043 D4).
|
||
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
|
||
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
|
||
}
|
||
// Table-level `[constraint <name>] foreign key (<col>)
|
||
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
||
MatchedKind::Word("foreign") => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||
items.next(); // `key`
|
||
}
|
||
// `( <child column> [, <child column>]* )` — a compound
|
||
// FK lists multiple child columns (ADR-0043).
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||
items.next();
|
||
}
|
||
let mut child_columns = Vec::new();
|
||
while let Some(it) = items.peek() {
|
||
match &it.kind {
|
||
MatchedKind::Punct(')') => break,
|
||
MatchedKind::Punct(',') => {
|
||
items.next();
|
||
}
|
||
_ => child_columns.push(items.next().expect("peeked").text.clone()),
|
||
}
|
||
}
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||
items.next();
|
||
}
|
||
// `references <parent> …`
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||
items.next();
|
||
}
|
||
let fk =
|
||
consume_fk_reference(&mut items, pending_fk_name.take(), child_columns, false);
|
||
foreign_keys.push(fk);
|
||
}
|
||
// Track paren depth for element-boundary detection. The
|
||
// column/`check`/`default`/table-`unique`/FK arms consume
|
||
// their own parens, so the only parens reaching here are the
|
||
// outer column list, type length-args, and
|
||
// table-`PRIMARY KEY (…)`.
|
||
MatchedKind::Punct('(') => depth += 1,
|
||
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
|
||
// A comma at the column-list interior ends the current
|
||
// element — the next element starts fresh (no column open).
|
||
MatchedKind::Punct(',') if depth == 1 => column_open = false,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// De-dup redundant flags off a sole primary-key column (ADR-0035
|
||
// §6.5): a single-column PK is already NOT NULL + UNIQUE, so
|
||
// emitting them again would create a spurious index. Advanced mode
|
||
// accepts the redundant spelling (real SQL does) rather than
|
||
// rejecting it like simple mode (ADR-0029 §9).
|
||
if primary_key.len() == 1
|
||
&& let Some(c) = columns.iter_mut().find(|c| c.name == primary_key[0])
|
||
{
|
||
c.not_null = false;
|
||
c.unique = false;
|
||
}
|
||
|
||
Ok(Command::SqlCreateTable {
|
||
name,
|
||
columns,
|
||
primary_key,
|
||
unique_constraints,
|
||
check_constraints,
|
||
foreign_keys,
|
||
if_not_exists,
|
||
})
|
||
}
|
||
|
||
/// Capture the byte span of a `DEFAULT <expr>` expression from the
|
||
/// matched-item stream (ADR-0035 §4a.2). Consumes the expression's
|
||
/// terminals (tracking paren depth) and stops *without consuming* the
|
||
/// next depth-0 element boundary (`,` / `)`) or constraint keyword
|
||
/// (`not` / `unique` / `primary` / `check`) — those terminals were
|
||
/// matched by the following constraint/element, not by the expression.
|
||
/// Returns `(start, end)` byte offsets, or `None` if no expression
|
||
/// terminal followed.
|
||
fn capture_expr_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
|
||
where
|
||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||
{
|
||
let mut depth = 0usize;
|
||
let mut start: Option<usize> = None;
|
||
let mut end = 0usize;
|
||
while let Some(it) = items.peek() {
|
||
match &it.kind {
|
||
MatchedKind::Punct(',' | ')') if depth == 0 => break,
|
||
MatchedKind::Word("not" | "unique" | "primary" | "check") if depth == 0 => break,
|
||
_ => {
|
||
match &it.kind {
|
||
MatchedKind::Punct('(') => depth += 1,
|
||
MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
|
||
_ => {}
|
||
}
|
||
start.get_or_insert(it.span.0);
|
||
end = it.span.1;
|
||
items.next();
|
||
}
|
||
}
|
||
}
|
||
start.map(|s| (s, end))
|
||
}
|
||
|
||
/// Capture the byte span of the contents of a parenthesised group
|
||
/// (`CHECK ( <expr> )`) from the matched-item stream — the next item
|
||
/// must be the opening `(`. Consumes through the matching `)` (tracking
|
||
/// nested parens) and returns the `(start, end)` offsets of the text
|
||
/// *between* the parens, or `None` if no `(` follows.
|
||
fn capture_parenthesised_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
|
||
where
|
||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||
{
|
||
if !matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||
return None;
|
||
}
|
||
let open = items.next()?; // '('
|
||
let inner_start = open.span.1;
|
||
let mut depth = 1usize;
|
||
let mut inner_end = inner_start;
|
||
for it in items.by_ref() {
|
||
match &it.kind {
|
||
MatchedKind::Punct('(') => depth += 1,
|
||
MatchedKind::Punct(')') => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
inner_end = it.span.0;
|
||
break;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Some((inner_start, inner_end))
|
||
}
|
||
|
||
/// Consume the tail of a foreign-key reference from the matched-item
|
||
/// stream (ADR-0035 §5, sub-phase 4b): the parent table ident, an
|
||
/// optional `( <parent col> )`, and any `on <delete|update> <action>`
|
||
/// clauses. The next item must be the parent-table ident (the
|
||
/// `references` keyword was already consumed by the caller). The
|
||
/// reference's own parens are consumed here, so they never reach the
|
||
/// builder's element-boundary `depth` tracker.
|
||
fn consume_fk_reference<'a, I>(
|
||
items: &mut std::iter::Peekable<I>,
|
||
name: Option<String>,
|
||
child_columns: Vec<String>,
|
||
inline: bool,
|
||
) -> SqlForeignKey
|
||
where
|
||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||
{
|
||
let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
|
||
// Optional `( <parent column> [, <parent column>]* )` — a
|
||
// compound FK references multiple parent columns (ADR-0043).
|
||
// `None` for the bare `REFERENCES <parent>` form.
|
||
let parent_columns: Option<Vec<String>> =
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||
items.next(); // `(`
|
||
let mut cols = Vec::new();
|
||
while let Some(it) = items.peek() {
|
||
match &it.kind {
|
||
MatchedKind::Punct(')') => break,
|
||
MatchedKind::Punct(',') => {
|
||
items.next();
|
||
}
|
||
_ => cols.push(items.next().expect("peeked").text.clone()),
|
||
}
|
||
}
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||
items.next(); // `)`
|
||
}
|
||
Some(cols)
|
||
} else {
|
||
None
|
||
};
|
||
// `on <delete|update> <action>` clauses, in either order, 0..2.
|
||
let mut on_delete = ReferentialAction::default_action();
|
||
let mut on_update = ReferentialAction::default_action();
|
||
while matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("on"))) {
|
||
items.next(); // `on`
|
||
let target = items.next().map(|it| it.kind.clone());
|
||
let action = consume_referential_action(items);
|
||
match target {
|
||
Some(MatchedKind::Word("delete")) => on_delete = action,
|
||
Some(MatchedKind::Word("update")) => on_update = action,
|
||
_ => {}
|
||
}
|
||
}
|
||
SqlForeignKey {
|
||
name,
|
||
child_columns,
|
||
parent_table,
|
||
parent_columns,
|
||
on_delete,
|
||
on_update,
|
||
inline,
|
||
}
|
||
}
|
||
|
||
/// Read a single referential action (`cascade` / `restrict` /
|
||
/// `set null` / `no action`) from the matched-item stream — the
|
||
/// two-word forms (`set null`, `no action`) consume their second word.
|
||
fn consume_referential_action<'a, I>(items: &mut std::iter::Peekable<I>) -> ReferentialAction
|
||
where
|
||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||
{
|
||
match items.next().map(|it| it.kind.clone()) {
|
||
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
|
||
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
|
||
Some(MatchedKind::Word("set")) => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
|
||
items.next();
|
||
}
|
||
ReferentialAction::SetNull
|
||
}
|
||
Some(MatchedKind::Word("no")) => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
|
||
items.next();
|
||
}
|
||
ReferentialAction::NoAction
|
||
}
|
||
_ => ReferentialAction::default_action(),
|
||
}
|
||
}
|
||
|
||
pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
|
||
entry: Word::keyword("create"),
|
||
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
|
||
ast_builder: build_sql_create_table,
|
||
help_id: Some("ddl.sql_create_table"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.sql_create_table"],
|
||
};
|
||
|
||
/// Build a `Command::SqlDropTable` from the advanced-mode SQL
|
||
/// `DROP TABLE [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4c).
|
||
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
|
||
/// flag (mirroring `build_sql_create_table`'s `if_not_exists`).
|
||
fn build_sql_drop_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
Ok(Command::SqlDropTable {
|
||
name: require_ident(path, "table_name")?,
|
||
if_exists: path.contains_word("if"),
|
||
})
|
||
}
|
||
|
||
pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||
entry: Word::keyword("drop"),
|
||
shape: SQL_DROP_TABLE_SHAPE,
|
||
ast_builder: build_sql_drop_table,
|
||
help_id: Some("ddl.sql_drop_table"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.sql_drop_table"],
|
||
};
|
||
|
||
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
|
||
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
|
||
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
|
||
/// flag (mirroring `build_sql_drop_table`).
|
||
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
Ok(Command::SqlDropIndex {
|
||
name: require_ident(path, "index_name")?,
|
||
if_exists: path.contains_word("if"),
|
||
})
|
||
}
|
||
|
||
pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||
entry: Word::keyword("drop"),
|
||
shape: SQL_DROP_INDEX_SHAPE,
|
||
ast_builder: build_sql_drop_index,
|
||
help_id: Some("ddl.sql_drop_index"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.sql_drop_index"],
|
||
};
|
||
|
||
// =================================================================
|
||
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
|
||
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
|
||
// node (alongside SQL_CREATE_TABLE).
|
||
// =================================================================
|
||
|
||
// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
|
||
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
||
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
||
// builder reads `unique` presence via `contains_word("unique")`.
|
||
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
|
||
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
|
||
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
||
static SQL_CI_LEAD_CHOICES: &[Node] =
|
||
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
||
|
||
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("if")),
|
||
Node::Word(Word::keyword("not")),
|
||
Node::Word(Word::keyword("exists")),
|
||
];
|
||
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));
|
||
|
||
// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
|
||
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
|
||
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
|
||
// instead greedily consume the `on` keyword (`consume_ident` does not
|
||
// reject keywords), breaking the unnamed form.
|
||
static SQL_CI_UNNAMED_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("on")),
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct('('),
|
||
INDEX_COLUMN_LIST,
|
||
Node::Punct(')'),
|
||
];
|
||
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
|
||
static SQL_CI_NAMED_NODES: &[Node] = &[
|
||
INDEX_NAME_NEW,
|
||
Node::Word(Word::keyword("on")),
|
||
TABLE_NAME_EXISTING,
|
||
Node::Punct('('),
|
||
INDEX_COLUMN_LIST,
|
||
Node::Punct(')'),
|
||
];
|
||
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
|
||
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
|
||
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);
|
||
|
||
static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
|
||
SQL_CI_LEAD,
|
||
SQL_CI_IF_NOT_EXISTS_OPT,
|
||
SQL_CI_SELECTOR,
|
||
Node::Optional(&Node::Punct(';')),
|
||
];
|
||
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);
|
||
|
||
/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
|
||
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
|
||
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
|
||
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
|
||
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
|
||
/// extraction mirrors the simple `add index` builder.
|
||
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||
Ok(Command::SqlCreateIndex {
|
||
name: ident(path, "index_name").map(str::to_string),
|
||
table: require_ident(path, "table_name")?,
|
||
columns: collect_idents(path, "column_name"),
|
||
unique: path.contains_word("unique"),
|
||
if_not_exists: path.contains_word("if"),
|
||
})
|
||
}
|
||
|
||
pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||
entry: Word::keyword("create"),
|
||
shape: SQL_CREATE_INDEX_SHAPE,
|
||
ast_builder: build_sql_create_index,
|
||
help_id: Some("ddl.sql_create_index"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.sql_create_index"],
|
||
};
|
||
|
||
// =================================================================
|
||
// SQL `ALTER TABLE <T> <action>` (ADR-0035 §4, sub-phase 4e).
|
||
// `alter` is an advanced-*only* entry word (like `select`/`with`).
|
||
// Actions: ADD/DROP/RENAME COLUMN — the `COLUMN` keyword is required
|
||
// (reserves bare `RENAME TO` for 4h and `ADD CONSTRAINT` for 4g).
|
||
// =================================================================
|
||
|
||
// The ALTER table slot carries the SQL-family `reject_internal_table`
|
||
// validator (parse-time refusal; the executors guard the rest) and
|
||
// `writes_table` so the DROP/RENAME column slot narrows to its columns.
|
||
const AT_TABLE_NAME: Node = Node::Ident {
|
||
source: IdentSource::Tables,
|
||
role: "table_name",
|
||
validator: Some(super::sql_select::reject_internal_table),
|
||
highlight_override: None,
|
||
writes_table: true,
|
||
writes_column: false,
|
||
writes_user_listed_column: false,
|
||
writes_table_alias: false,
|
||
writes_cte_name: false,
|
||
writes_projection_alias: false,
|
||
};
|
||
|
||
// ADD COLUMN's constraint suffix — the SQL leaf nodes for NOT NULL /
|
||
// UNIQUE / DEFAULT / CHECK only. PK and inline REFERENCES are
|
||
// deliberately excluded (PK is invalid on ADD COLUMN; REFERENCES is 4g).
|
||
static AT_ADD_CONSTRAINT_CHOICES: &[Node] = &[
|
||
Node::Seq(super::sql_create_table::NOT_NULL_NODES),
|
||
Node::Word(Word::keyword("unique")),
|
||
Node::Seq(super::sql_create_table::DEFAULT_NODES),
|
||
Node::Seq(super::sql_create_table::CHECK_NODES),
|
||
];
|
||
const AT_ADD_CONSTRAINT: Node = Node::Choice(AT_ADD_CONSTRAINT_CHOICES);
|
||
const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||
inner: &AT_ADD_CONSTRAINT,
|
||
separator: None,
|
||
min: 0,
|
||
};
|
||
|
||
// The walker's `Choice` selects a branch by its **leading** token and
|
||
// does not backtrack into a sibling once a branch's first keyword
|
||
// matched. So the action `Choice` keeps ONE branch per leading verb
|
||
// (`add`/`drop`/`rename`/`alter`); the `add` and `drop` verbs then
|
||
// fan out to an **inner** `Choice` whose branches each lead on a
|
||
// *distinct* second keyword (column / constraint / check / unique /
|
||
// foreign / primary), so no two same-led branches ever sit in one
|
||
// `Choice`.
|
||
|
||
// `add column <col> <type> [constraints]` — the column-def tail (the
|
||
// leading `add` is consumed by `AT_ADD`).
|
||
static AT_ADD_COLUMN_TAIL_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
super::sql_create_table::COL_NAME,
|
||
super::sql_create_table::SQL_TYPE,
|
||
AT_ADD_CONSTRAINT_SUFFIX,
|
||
];
|
||
const AT_ADD_COLUMN_TAIL: Node = Node::Seq(AT_ADD_COLUMN_TAIL_NODES);
|
||
|
||
// `drop column <col>` / `drop constraint <name>` tails (leading `drop`
|
||
// consumed by `AT_DROP`).
|
||
static AT_DROP_COLUMN_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("column")), COLUMN_NAME];
|
||
const AT_DROP_COLUMN_TAIL: Node = Node::Seq(AT_DROP_COLUMN_TAIL_NODES);
|
||
|
||
// New-table-name slot for `RENAME TO <new>` (ADR-0035 §6, sub-phase 4h).
|
||
// Mirrors the `CREATE TABLE` name slot: `IdentSource::NewName` (a name
|
||
// being introduced, not completed from existing tables) + the same
|
||
// `reject_internal_table` parse-time validator, so an `__rdbms_*` target
|
||
// is refused before submit. Wrapped in `NEW_NAME_HINT` like
|
||
// `NEW_COLUMN_NAME`. `writes_table: false` — nothing downstream of
|
||
// `rename to <new>` references the schema cache.
|
||
const NEW_TABLE_NAME_IDENT: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "new_table_name",
|
||
validator: Some(super::sql_select::reject_internal_table),
|
||
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,
|
||
};
|
||
const NEW_TABLE_NAME: Node = Node::Hinted {
|
||
mode: NEW_NAME_HINT,
|
||
inner: &NEW_TABLE_NAME_IDENT,
|
||
};
|
||
|
||
// The `rename` verb fans out (like `add`/`drop`, §6.1) to an inner
|
||
// `Choice` whose two tails lead on DISTINCT second keywords: `column`
|
||
// (rename column) and `to` (rename table — 4h). The walker `Choice`
|
||
// selects by the leading token and never backtracks between branches, so
|
||
// the distinct keywords keep them apart.
|
||
static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("column")),
|
||
COLUMN_NAME,
|
||
Node::Word(Word::keyword("to")),
|
||
NEW_COLUMN_NAME,
|
||
];
|
||
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
|
||
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
|
||
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
||
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
|
||
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
|
||
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
|
||
static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL];
|
||
const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES);
|
||
|
||
// `ALTER COLUMN <col> <action>` (ADR-0035 §4f + Amendment 2). The action
|
||
// tail is a Choice on distinct concrete keywords — `type` / `set` /
|
||
// `drop` — trap-safe. The type slot reuses SQL_TYPE (the same alias map +
|
||
// `double precision` pair the CREATE TABLE / ADD COLUMN forms use).
|
||
//
|
||
// TYPE <ty> — PostgreSQL shorthand (§4f)
|
||
// SET DATA TYPE <ty> — ISO canonical synonym (Amendment 2)
|
||
// SET NOT NULL — documented extension (Amendment 2)
|
||
// SET DEFAULT <expr> — ISO (Amendment 2), raw sql_expr text
|
||
// DROP NOT NULL — ISO-ish (Amendment 2)
|
||
// DROP DEFAULT — ISO (Amendment 2)
|
||
//
|
||
// `NOT NULL` reused by both SET and DROP tails (distinct sibling leads in
|
||
// each). `DEFAULT <expr>` reuses the CREATE TABLE `DEFAULT_NODES` so a
|
||
// default is one syntax, captured as raw text (sql_expr builds no AST).
|
||
static AT_AC_TYPE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("type")),
|
||
super::sql_create_table::SQL_TYPE,
|
||
];
|
||
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
|
||
static AT_AC_NOT_NULL_NODES: &[Node] =
|
||
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
|
||
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
|
||
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("data")),
|
||
Node::Word(Word::keyword("type")),
|
||
super::sql_create_table::SQL_TYPE,
|
||
];
|
||
const AT_AC_SET_DATA_TYPE: Node = Node::Seq(AT_AC_SET_DATA_TYPE_NODES);
|
||
static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
|
||
AT_AC_SET_DATA_TYPE,
|
||
AT_AC_NOT_NULL,
|
||
Node::Seq(super::sql_create_table::DEFAULT_NODES),
|
||
];
|
||
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
|
||
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
|
||
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
|
||
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
|
||
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
|
||
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
|
||
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
|
||
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
|
||
static AT_ALTER_COLUMN_TAIL_CHOICES: &[Node] = &[AT_AC_TYPE, AT_AC_SET, AT_AC_DROP];
|
||
const AT_ALTER_COLUMN_TAIL: Node = Node::Choice(AT_ALTER_COLUMN_TAIL_CHOICES);
|
||
static AT_ALTER_COLUMN_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("alter")),
|
||
Node::Word(Word::keyword("column")),
|
||
COLUMN_NAME,
|
||
AT_ALTER_COLUMN_TAIL,
|
||
];
|
||
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
|
||
|
||
// --- 4g: ADD [CONSTRAINT <name>] table-constraint / DROP CONSTRAINT ---
|
||
//
|
||
// `ADD [CONSTRAINT <name>] (check (…) | unique (…) | foreign key (…)
|
||
// references … | primary key (…))` and `DROP CONSTRAINT <name>`
|
||
// (ADR-0035 §4g). The constraint bodies reuse the `sql_create_table`
|
||
// table-element nodes; the §4g name comes from the `CONSTRAINT <name>`
|
||
// prefix (a dedicated `constraint`-led inner branch — never a leading
|
||
// `Optional`). UNIQUE/PRIMARY KEY may carry a name syntactically but the
|
||
// builder refuses it (composite UNIQUE is anonymous; PK is unsupported).
|
||
const CONSTRAINT_NAME: Node = Node::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "constraint_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,
|
||
};
|
||
// The constraint bodies — each leads on a distinct concrete keyword.
|
||
static AT_CONSTRAINT_BODY_CHOICES: &[Node] = &[
|
||
super::sql_create_table::TABLE_CHECK,
|
||
super::sql_create_table::TABLE_UNIQUE,
|
||
super::sql_create_table::TABLE_FK,
|
||
// `primary key (…)` parses so the builder can refuse it with a
|
||
// specific message rather than a generic "unexpected `primary`".
|
||
super::sql_create_table::TABLE_PK,
|
||
];
|
||
const AT_CONSTRAINT_BODY: Node = Node::Choice(AT_CONSTRAINT_BODY_CHOICES);
|
||
// `constraint <name> <body>` — the named-constraint tail (leads on the
|
||
// concrete `constraint` keyword, so it is a safe `Choice` sibling).
|
||
static AT_CONSTRAINT_NAMED_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("constraint")),
|
||
CONSTRAINT_NAME,
|
||
AT_CONSTRAINT_BODY,
|
||
];
|
||
const AT_CONSTRAINT_NAMED: Node = Node::Seq(AT_CONSTRAINT_NAMED_NODES);
|
||
|
||
// The `add` tail: a column def, a named constraint, or one of the bare
|
||
// (unnamed) constraint bodies — each branch leads on a distinct keyword
|
||
// (column / constraint / check / unique / foreign / primary).
|
||
static AT_ADD_TAIL_CHOICES: &[Node] = &[
|
||
AT_ADD_COLUMN_TAIL,
|
||
AT_CONSTRAINT_NAMED,
|
||
super::sql_create_table::TABLE_CHECK,
|
||
super::sql_create_table::TABLE_UNIQUE,
|
||
super::sql_create_table::TABLE_FK,
|
||
super::sql_create_table::TABLE_PK,
|
||
];
|
||
const AT_ADD_TAIL: Node = Node::Choice(AT_ADD_TAIL_CHOICES);
|
||
static AT_ADD_NODES: &[Node] = &[Node::Word(Word::keyword("add")), AT_ADD_TAIL];
|
||
const AT_ADD: Node = Node::Seq(AT_ADD_NODES);
|
||
|
||
// The `drop` tail: a column or a named constraint (distinct second
|
||
// keywords `column` / `constraint`).
|
||
static AT_DROP_CONSTRAINT_TAIL_NODES: &[Node] =
|
||
&[Node::Word(Word::keyword("constraint")), CONSTRAINT_NAME];
|
||
const AT_DROP_CONSTRAINT_TAIL: Node = Node::Seq(AT_DROP_CONSTRAINT_TAIL_NODES);
|
||
static AT_DROP_TAIL_CHOICES: &[Node] = &[AT_DROP_COLUMN_TAIL, AT_DROP_CONSTRAINT_TAIL];
|
||
const AT_DROP_TAIL: Node = Node::Choice(AT_DROP_TAIL_CHOICES);
|
||
static AT_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_DROP_TAIL];
|
||
const AT_DROP: Node = Node::Seq(AT_DROP_NODES);
|
||
|
||
// One branch per leading verb (`add`/`drop`/`rename`/`alter`) — distinct
|
||
// concrete keywords, trap-safe. (The branch's `alter` is the action
|
||
// word; the entry-word `alter` was already consumed by dispatch.) The
|
||
// second-keyword fan-out happens in `AT_ADD` / `AT_DROP`'s inner Choice.
|
||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME, AT_ALTER_COLUMN];
|
||
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
|
||
|
||
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
|
||
Node::Word(Word::keyword("table")),
|
||
AT_TABLE_NAME,
|
||
AT_ACTION,
|
||
Node::Optional(&Node::Punct(';')),
|
||
];
|
||
const SQL_ALTER_TABLE_SHAPE: Node = Node::Seq(SQL_ALTER_TABLE_SHAPE_NODES);
|
||
|
||
/// Build the single `ColumnSpec` for an `ALTER TABLE … ADD COLUMN`
|
||
/// (ADR-0035 §4e). Mirrors the SQL `CREATE TABLE` per-column extraction
|
||
/// for one column: DEFAULT/CHECK are captured as **raw text** by byte
|
||
/// span (`sql_expr` builds no AST — 4a.2), so the executor consumes
|
||
/// `default_sql`/`check_sql`.
|
||
fn build_alter_add_column_spec(
|
||
path: &MatchedPath,
|
||
source: &str,
|
||
) -> Result<ColumnSpec, ValidationError> {
|
||
let mut spec: Option<ColumnSpec> = None;
|
||
let mut pending_name: Option<String> = None;
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
match &item.kind {
|
||
MatchedKind::Ident { role: "col_name", .. } => {
|
||
pending_name = Some(item.text.clone());
|
||
}
|
||
MatchedKind::Ident { role: "col_type", .. } => {
|
||
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?;
|
||
let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||
spec = Some(ColumnSpec::new(name, ty));
|
||
}
|
||
MatchedKind::Word("double") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("precision"))
|
||
) {
|
||
items.next();
|
||
}
|
||
let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||
spec = Some(ColumnSpec::new(name, Type::Real));
|
||
}
|
||
MatchedKind::Word("not") => {
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
|
||
items.next();
|
||
if let Some(s) = spec.as_mut() {
|
||
s.not_null = true;
|
||
}
|
||
}
|
||
}
|
||
MatchedKind::Word("unique") => {
|
||
if let Some(s) = spec.as_mut() {
|
||
s.unique = true;
|
||
}
|
||
}
|
||
MatchedKind::Word("default") => {
|
||
if let Some((start, end)) = capture_expr_span(&mut items)
|
||
&& let Some(s) = spec.as_mut()
|
||
{
|
||
s.default_sql = Some(source[start..end].trim().to_string());
|
||
}
|
||
}
|
||
MatchedKind::Word("check") => {
|
||
if let Some((start, end)) = capture_parenthesised_span(&mut items)
|
||
&& let Some(s) = spec.as_mut()
|
||
{
|
||
s.check_sql = Some(source[start..end].trim().to_string());
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
spec.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "add column needs a name and type".to_string())],
|
||
})
|
||
}
|
||
|
||
/// Extract the `ALTER COLUMN <col> TYPE <type>` action (ADR-0035 §4f).
|
||
/// The type slot reuses SQL_TYPE, so the target-type extraction mirrors
|
||
/// `build_alter_add_column_spec`'s: a `col_type` ident via
|
||
/// `Type::from_sql_name` (alias map applied), or the two-word
|
||
/// `double precision` → `Type::Real`.
|
||
fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, ValidationError> {
|
||
let column = require_ident(path, "column_name")?;
|
||
let mut ty: Option<Type> = None;
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
match &item.kind {
|
||
MatchedKind::Ident { role: "col_type", .. } => {
|
||
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "unknown type".to_string())],
|
||
})?);
|
||
}
|
||
MatchedKind::Word("double") => {
|
||
if matches!(
|
||
items.peek().map(|i| &i.kind),
|
||
Some(MatchedKind::Word("precision"))
|
||
) {
|
||
items.next();
|
||
}
|
||
ty = Some(Type::Real);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let ty = ty.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "alter column needs a target type".to_string())],
|
||
})?;
|
||
Ok(AlterTableAction::AlterColumnType { column, ty })
|
||
}
|
||
|
||
/// Build an `ALTER COLUMN <c> SET/DROP NOT NULL` / `SET/DROP DEFAULT`
|
||
/// action (ADR-0035 Amendment 2). `SET DATA TYPE` is handled by the
|
||
/// `type`-keyword branch, so this only sees the four constraint forms.
|
||
/// The 2×2 is decided by `set` (vs drop) and `default` (vs not null);
|
||
/// `SET DEFAULT`'s value is captured as raw `sql_expr` text by span,
|
||
/// reusing `build_alter_add_column_spec`'s mechanism (no AST — 4a.2).
|
||
fn build_alter_column_attr(
|
||
path: &MatchedPath,
|
||
source: &str,
|
||
) -> Result<AlterTableAction, ValidationError> {
|
||
let column = require_ident(path, "column_name")?;
|
||
let is_set = path.contains_word("set");
|
||
let is_default = path.contains_word("default");
|
||
Ok(match (is_set, is_default) {
|
||
(true, true) => {
|
||
let mut items = path.items.iter().peekable();
|
||
let mut default_sql: Option<String> = None;
|
||
while let Some(item) = items.next() {
|
||
if matches!(&item.kind, MatchedKind::Word("default"))
|
||
&& let Some((start, end)) = capture_expr_span(&mut items)
|
||
{
|
||
default_sql = Some(source[start..end].trim().to_string());
|
||
}
|
||
}
|
||
let default_sql = default_sql.ok_or_else(|| ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "set default needs a value".to_string())],
|
||
})?;
|
||
AlterTableAction::SetColumnDefault { column, default_sql }
|
||
}
|
||
(false, true) => AlterTableAction::DropColumnDefault { column },
|
||
(true, false) => AlterTableAction::SetColumnNotNull { column },
|
||
(false, false) => AlterTableAction::DropColumnNotNull { column },
|
||
})
|
||
}
|
||
|
||
/// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one
|
||
/// action `Choice` branch matched; the builder recovers which from the
|
||
/// matched words. Discrimination order matters:
|
||
///
|
||
/// 1. **`type`** first — unique to ALTER COLUMN TYPE (ADD COLUMN's type
|
||
/// is a `col_type` *ident*, not the literal word), and an `alter
|
||
/// column …` input also contains `column`, so it must be caught
|
||
/// before the column branch.
|
||
/// 2. **`column`** — the column ops (add/drop/rename column), routed by
|
||
/// `add`/`rename`/else-drop. Checked before the bare `add`/`drop`
|
||
/// keywords so `add column … unique`/`… check` (a column constraint)
|
||
/// still routes to AddColumn.
|
||
/// 3. **`add`** — a table-level constraint (CHECK / UNIQUE / FK / the
|
||
/// refused PRIMARY KEY).
|
||
/// 4. **`rename`** — `rename to <new>` (table rename, 4h). Reached only
|
||
/// when `column` is absent (caught by step 2), so a lone `rename`
|
||
/// means the table form. The new name binds a *distinct* role
|
||
/// (`new_table_name`), so it never collides with the `table_name`
|
||
/// target slot.
|
||
/// 5. else **`drop`** — `drop constraint <name>`.
|
||
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||
let table = require_ident(path, "table_name")?;
|
||
let action = if path.contains_word("type") {
|
||
// covers `TYPE <ty>` and the ISO synonym `SET DATA TYPE <ty>`
|
||
build_alter_column_type(path)?
|
||
} else if path.contains_word("column")
|
||
&& !path.contains_word("add")
|
||
&& !path.contains_word("rename")
|
||
&& (path.contains_word("set")
|
||
|| path.contains_word("default")
|
||
|| (path.contains_word("not") && path.contains_word("null")))
|
||
{
|
||
// ADR-0035 Amendment 2: `ALTER COLUMN <c> SET/DROP NOT NULL` and
|
||
// `SET/DROP DEFAULT`. `add column … not null/default` is excluded
|
||
// by `!add`; plain `drop column` lacks the attribute markers.
|
||
build_alter_column_attr(path, source)?
|
||
} else if path.contains_word("column") {
|
||
if path.contains_word("add") {
|
||
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
|
||
} else if path.contains_word("rename") {
|
||
AlterTableAction::RenameColumn {
|
||
old: require_ident(path, "column_name")?,
|
||
new: require_ident(path, "new_column_name")?,
|
||
}
|
||
} else {
|
||
AlterTableAction::DropColumn {
|
||
column: require_ident(path, "column_name")?,
|
||
}
|
||
}
|
||
} else if path.contains_word("add") {
|
||
build_alter_add_table_constraint(path, source)?
|
||
} else if path.contains_word("rename") {
|
||
AlterTableAction::RenameTable {
|
||
new: require_ident(path, "new_table_name")?,
|
||
}
|
||
} else {
|
||
AlterTableAction::DropConstraint {
|
||
name: require_ident(path, "constraint_name")?,
|
||
}
|
||
};
|
||
Ok(Command::SqlAlterTable { table, action })
|
||
}
|
||
|
||
/// Build the `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY | …)`
|
||
/// action (ADR-0035 §4g). The body is discriminated by its leading
|
||
/// concrete keyword. The optional `CONSTRAINT <name>` prefix becomes the
|
||
/// action-level `name` (used by CHECK + FK at execution; refused on
|
||
/// UNIQUE). `ADD PRIMARY KEY` parses (for a clean message) but is
|
||
/// refused — every playground table already has a PK.
|
||
fn build_alter_add_table_constraint(
|
||
path: &MatchedPath,
|
||
source: &str,
|
||
) -> Result<AlterTableAction, ValidationError> {
|
||
let name = ident(path, "constraint_name").map(str::to_string);
|
||
if path.contains_word("primary") {
|
||
return Err(ValidationError {
|
||
message_key: "parse.custom.alter_add_primary_key",
|
||
args: vec![],
|
||
});
|
||
}
|
||
let constraint = if path.contains_word("check") {
|
||
TableConstraint::Check {
|
||
expr_sql: capture_table_check_sql(path, source)?,
|
||
}
|
||
} else if path.contains_word("unique") {
|
||
if name.is_some() {
|
||
return Err(ValidationError {
|
||
message_key: "parse.custom.alter_named_unique",
|
||
args: vec![],
|
||
});
|
||
}
|
||
TableConstraint::Unique {
|
||
columns: collect_idents(path, "unique_column"),
|
||
}
|
||
} else {
|
||
// FOREIGN KEY — the §4g name lives at the action level, so the
|
||
// FK body itself is parsed unnamed.
|
||
TableConstraint::ForeignKey(build_alter_fk(path))
|
||
};
|
||
Ok(AlterTableAction::AddTableConstraint {
|
||
name,
|
||
constraint: Box::new(constraint),
|
||
})
|
||
}
|
||
|
||
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
|
||
/// `sql_expr` is validate-only, so the expression is captured by byte
|
||
/// span — the 4a.2 / 4e mechanism.
|
||
fn capture_table_check_sql(
|
||
path: &MatchedPath,
|
||
source: &str,
|
||
) -> Result<String, ValidationError> {
|
||
let mut items = path.items.iter().peekable();
|
||
while let Some(item) = items.next() {
|
||
if matches!(item.kind, MatchedKind::Word("check"))
|
||
&& let Some((start, end)) = capture_parenthesised_span(&mut items)
|
||
{
|
||
return Ok(source[start..end].trim().to_string());
|
||
}
|
||
}
|
||
Err(ValidationError {
|
||
message_key: "parse.error_wrapper",
|
||
args: vec![("detail", "add check needs an expression".to_string())],
|
||
})
|
||
}
|
||
|
||
/// Build the `SqlForeignKey` for an `ADD [CONSTRAINT <name>] FOREIGN KEY
|
||
/// (<col>) REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Mirrors the
|
||
/// table-level FK walk in `build_sql_create_table`, reusing
|
||
/// `consume_fk_reference`. The name is supplied at the action level (so
|
||
/// the FK is parsed unnamed here).
|
||
fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
||
let mut items = path.items.iter().peekable();
|
||
// Advance to the `foreign` keyword.
|
||
while items
|
||
.peek()
|
||
.is_some_and(|it| !matches!(it.kind, MatchedKind::Word("foreign")))
|
||
{
|
||
items.next();
|
||
}
|
||
items.next(); // `foreign`
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||
items.next();
|
||
}
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||
items.next();
|
||
}
|
||
// `( <child column> [, <child column>]* )` — compound FK (ADR-0043).
|
||
let mut child_columns = Vec::new();
|
||
while let Some(it) = items.peek() {
|
||
match &it.kind {
|
||
MatchedKind::Punct(')') => break,
|
||
MatchedKind::Punct(',') => {
|
||
items.next();
|
||
}
|
||
_ => child_columns.push(items.next().expect("peeked").text.clone()),
|
||
}
|
||
}
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||
items.next();
|
||
}
|
||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||
items.next();
|
||
}
|
||
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
|
||
consume_fk_reference(&mut items, None, child_columns, false)
|
||
}
|
||
|
||
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||
entry: Word::keyword("alter"),
|
||
shape: SQL_ALTER_TABLE_SHAPE,
|
||
ast_builder: build_sql_alter_table,
|
||
help_id: Some("ddl.sql_alter_table"),
|
||
hint_ids: &[],
|
||
usage_ids: &["parse.usage.sql_alter_table"],
|
||
};
|
||
|
||
// =================================================================
|
||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||
// =================================================================
|
||
|
||
#[cfg(test)]
|
||
mod constraint_tests {
|
||
use super::{Command, Constraint, ConstraintKind};
|
||
use crate::dsl::command::ColumnSpec;
|
||
use crate::dsl::parser::parse_command;
|
||
use crate::dsl::value::Value;
|
||
|
||
/// Parse a `create table` and return its column specs.
|
||
fn create_columns(input: &str) -> Vec<ColumnSpec> {
|
||
match parse_command(input).expect("create table should parse") {
|
||
Command::CreateTable { columns, .. } => columns,
|
||
other => panic!("expected CreateTable, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn create_table_parses_a_text_default() {
|
||
// `grade` is the (single) PK column; `default` is
|
||
// allowed on a PK column (ADR-0029 §9).
|
||
let cols = create_columns("create table T with pk grade(text) default 'A'");
|
||
assert_eq!(cols.len(), 1);
|
||
assert_eq!(cols[0].default, Some(Value::Text("A".to_string())));
|
||
assert!(!cols[0].not_null && !cols[0].unique);
|
||
}
|
||
|
||
#[test]
|
||
fn create_table_parses_a_numeric_default_on_a_compound_pk_member() {
|
||
let cols = create_columns("create table T with pk a(int), b(int) default 7");
|
||
assert_eq!(cols.len(), 2);
|
||
assert_eq!(cols[1].default, Some(Value::Number("7".to_string())));
|
||
}
|
||
|
||
#[test]
|
||
fn not_null_on_a_pk_column_is_a_redundancy_error() {
|
||
// Every `create table` column is a primary-key column,
|
||
// so `not null` is always redundant there (ADR-0029 §9).
|
||
assert!(parse_command("create table T with pk id(serial) not null").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn unique_on_a_single_column_pk_is_a_redundancy_error() {
|
||
assert!(parse_command("create table T with pk code(text) unique").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn unique_on_a_compound_pk_member_is_allowed() {
|
||
// A compound PK does not make its members individually
|
||
// unique, so an explicit `unique` is meaningful there.
|
||
let cols = create_columns("create table T with pk a(int) unique, b(text)");
|
||
assert_eq!(cols.len(), 2);
|
||
assert!(cols[0].unique, "`a` carries an explicit UNIQUE");
|
||
assert!(!cols[1].unique);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unconstrained_create_table_still_parses() {
|
||
let cols = create_columns("create table T with pk id(serial), name(text)");
|
||
assert_eq!(cols.len(), 2);
|
||
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_parses_its_constraint_suffix() {
|
||
match parse_command("add column to T: tier (int) not null default 0")
|
||
.expect("add column should parse")
|
||
{
|
||
Command::AddColumn {
|
||
not_null,
|
||
unique,
|
||
default,
|
||
..
|
||
} => {
|
||
assert!(not_null);
|
||
assert!(!unique);
|
||
assert_eq!(default, Some(Value::Number("0".to_string())));
|
||
}
|
||
other => panic!("expected AddColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_parses_a_unique_constraint() {
|
||
match parse_command("add column to T: email (text) unique").expect("parse") {
|
||
Command::AddColumn { unique, not_null, .. } => {
|
||
assert!(unique);
|
||
assert!(!not_null);
|
||
}
|
||
other => panic!("expected AddColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn create_table_parses_a_check_constraint() {
|
||
let cols = create_columns("create table T with pk age(int) check (age >= 0)");
|
||
assert_eq!(cols.len(), 1);
|
||
assert!(cols[0].check.is_some(), "the column carries a CHECK");
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_parses_a_check_constraint() {
|
||
match parse_command("add column to T: age (int) check (age >= 0 and age < 150)")
|
||
.expect("parse")
|
||
{
|
||
Command::AddColumn { check, .. } => {
|
||
assert!(check.is_some(), "the column carries a CHECK");
|
||
}
|
||
other => panic!("expected AddColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn check_with_a_parenthesised_sub_expression_parses() {
|
||
// The check's own parens plus a nested group — the
|
||
// builder's paren-depth scan must pair them correctly.
|
||
let cols = create_columns(
|
||
"create table T with pk n(int) check ((n > 0) or (n < -10))",
|
||
);
|
||
assert!(cols[0].check.is_some());
|
||
}
|
||
|
||
// --- `add constraint` / `drop constraint` (ADR-0029 §2.2) ---
|
||
|
||
#[test]
|
||
fn add_constraint_not_null_parses() {
|
||
match parse_command("add constraint not null to Users.email").expect("parse") {
|
||
Command::AddConstraint {
|
||
table,
|
||
column,
|
||
constraint,
|
||
} => {
|
||
assert_eq!(table, "Users");
|
||
assert_eq!(column, "email");
|
||
assert_eq!(constraint, Constraint::NotNull);
|
||
}
|
||
other => panic!("expected AddConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_constraint_unique_parses() {
|
||
match parse_command("add constraint unique to Users.email").expect("parse") {
|
||
Command::AddConstraint { constraint, .. } => {
|
||
assert_eq!(constraint, Constraint::Unique);
|
||
}
|
||
other => panic!("expected AddConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_constraint_default_parses() {
|
||
match parse_command("add constraint default 18 to Users.age").expect("parse") {
|
||
Command::AddConstraint { constraint, .. } => {
|
||
assert_eq!(
|
||
constraint,
|
||
Constraint::Default(Value::Number("18".to_string()))
|
||
);
|
||
}
|
||
other => panic!("expected AddConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_constraint_check_parses() {
|
||
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
|
||
{
|
||
Command::AddConstraint {
|
||
column, constraint, ..
|
||
} => {
|
||
assert_eq!(column, "age");
|
||
assert!(matches!(constraint, Constraint::Check(_)));
|
||
}
|
||
other => panic!("expected AddConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_constraint_not_null_parses() {
|
||
match parse_command("drop constraint not null from Users.email").expect("parse") {
|
||
Command::DropConstraint {
|
||
table,
|
||
column,
|
||
kind,
|
||
} => {
|
||
assert_eq!(table, "Users");
|
||
assert_eq!(column, "email");
|
||
assert_eq!(kind, ConstraintKind::NotNull);
|
||
}
|
||
other => panic!("expected DropConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_constraint_each_kind_parses() {
|
||
for (input, expected) in [
|
||
("drop constraint unique from T.c", ConstraintKind::Unique),
|
||
("drop constraint default from T.c", ConstraintKind::Default),
|
||
("drop constraint check from T.c", ConstraintKind::Check),
|
||
] {
|
||
match parse_command(input).expect("parse") {
|
||
Command::DropConstraint { kind, .. } => assert_eq!(kind, expected),
|
||
other => panic!("expected DropConstraint for {input:?}, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// =================================================================
|
||
// Tests — advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c)
|
||
// =================================================================
|
||
|
||
#[cfg(test)]
|
||
mod sql_drop_table_tests {
|
||
use crate::dsl::command::Command;
|
||
use crate::dsl::parser::parse_command_in_mode;
|
||
use crate::mode::Mode;
|
||
|
||
fn drop_fields(input: &str) -> (String, bool) {
|
||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||
Command::SqlDropTable { name, if_exists } => (name, if_exists),
|
||
other => panic!("expected SqlDropTable, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_table_parses_as_sql_drop_table_in_advanced_mode() {
|
||
let (name, if_exists) = drop_fields("drop table Orders");
|
||
assert_eq!(name, "Orders");
|
||
assert!(!if_exists);
|
||
}
|
||
|
||
#[test]
|
||
fn if_exists_sets_the_flag() {
|
||
let (name, if_exists) = drop_fields("drop table if exists Orders");
|
||
assert_eq!(name, "Orders");
|
||
assert!(if_exists);
|
||
// trailing semicolon tolerated
|
||
assert!(drop_fields("drop table if exists Orders;").1);
|
||
}
|
||
|
||
#[test]
|
||
fn simple_drop_table_in_simple_mode_is_the_dsl_command() {
|
||
// In simple mode the SQL node is gated; `drop table T` is the
|
||
// simple `DropTable` (which has no `if_exists`).
|
||
match parse_command_in_mode("drop table Orders", Mode::Simple).expect("parses") {
|
||
Command::DropTable { name } => assert_eq!(name, "Orders"),
|
||
other => panic!("expected DropTable, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn other_drops_fall_back_to_the_simple_node_in_advanced_mode() {
|
||
// `drop column` / `drop relationship` are not SQL DROP TABLE —
|
||
// they fall through to the simple `drop` node even in advanced.
|
||
assert!(matches!(
|
||
parse_command_in_mode("drop column from Orders: note", Mode::Advanced).expect("parses"),
|
||
Command::DropColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
|
||
.expect("parses"),
|
||
Command::DropRelationship { .. }
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sql_drop_index_tests {
|
||
use crate::dsl::command::{Command, IndexSelector};
|
||
use crate::dsl::parser::parse_command_in_mode;
|
||
use crate::mode::Mode;
|
||
|
||
fn drop_index_fields(input: &str) -> (String, bool) {
|
||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||
Command::SqlDropIndex { name, if_exists } => (name, if_exists),
|
||
other => panic!("expected SqlDropIndex, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
|
||
let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
|
||
assert_eq!(name, "Orders_CustId_idx");
|
||
assert!(!if_exists);
|
||
}
|
||
|
||
#[test]
|
||
fn if_exists_sets_the_flag() {
|
||
let (name, if_exists) = drop_index_fields("drop index if exists ix");
|
||
assert_eq!(name, "ix");
|
||
assert!(if_exists);
|
||
// trailing semicolon tolerated
|
||
assert!(drop_index_fields("drop index if exists ix;").1);
|
||
}
|
||
|
||
#[test]
|
||
fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
|
||
// `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
|
||
// SQL_DROP_INDEX); the dispatcher must try both and pick the
|
||
// shape that matches (ADR-0035 §4d — the second-advanced-node
|
||
// case).
|
||
assert!(matches!(
|
||
parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
|
||
Command::SqlDropTable { .. }
|
||
));
|
||
assert!(matches!(
|
||
parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
|
||
Command::SqlDropIndex { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
|
||
// The SQL form is name-only; `drop index on T (cols)` is the
|
||
// simple positional form. The name-only SQL shape can't fully
|
||
// match it (trailing `(cols)`), so it falls back to the simple
|
||
// `drop` node's `DropIndex { Columns }` even in advanced mode.
|
||
match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
|
||
.expect("parses")
|
||
{
|
||
Command::DropIndex {
|
||
selector: IndexSelector::Columns { table, columns },
|
||
} => {
|
||
assert_eq!(table, "Orders");
|
||
assert_eq!(columns, vec!["CustId".to_string()]);
|
||
}
|
||
other => panic!("expected positional DropIndex, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn named_drop_index_in_simple_mode_is_the_dsl_command() {
|
||
// In simple mode the SQL node is gated; `drop index ix` is the
|
||
// simple `DropIndex { Named }`.
|
||
match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
|
||
Command::DropIndex {
|
||
selector: IndexSelector::Named { name },
|
||
} => assert_eq!(name, "ix"),
|
||
other => panic!("expected named DropIndex, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sql_create_index_tests {
|
||
use crate::dsl::command::Command;
|
||
use crate::dsl::parser::parse_command_in_mode;
|
||
use crate::mode::Mode;
|
||
|
||
struct Ci {
|
||
name: Option<String>,
|
||
table: String,
|
||
columns: Vec<String>,
|
||
unique: bool,
|
||
if_not_exists: bool,
|
||
}
|
||
|
||
fn ci(input: &str) -> Ci {
|
||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||
Command::SqlCreateIndex {
|
||
name,
|
||
table,
|
||
columns,
|
||
unique,
|
||
if_not_exists,
|
||
} => Ci { name, table, columns, unique, if_not_exists },
|
||
other => panic!("expected SqlCreateIndex, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn named_create_index_parses() {
|
||
let c = ci("create index ix on Customers (email)");
|
||
assert_eq!(c.name.as_deref(), Some("ix"));
|
||
assert_eq!(c.table, "Customers");
|
||
assert_eq!(c.columns, vec!["email".to_string()]);
|
||
assert!(!c.unique);
|
||
assert!(!c.if_not_exists);
|
||
}
|
||
|
||
#[test]
|
||
fn unnamed_create_index_leaves_name_none() {
|
||
// The unnamed form: the optional name must NOT swallow `on`
|
||
// (the `DI_SELECTOR`-style on-led-first selector handles it).
|
||
let c = ci("create index on Customers (email)");
|
||
assert_eq!(c.name, None);
|
||
assert_eq!(c.table, "Customers");
|
||
assert_eq!(c.columns, vec!["email".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn unique_sets_the_flag() {
|
||
let c = ci("create unique index ux on Customers (email)");
|
||
assert!(c.unique);
|
||
assert_eq!(c.name.as_deref(), Some("ux"));
|
||
// unnamed unique form too
|
||
let c2 = ci("create unique index on Customers (email)");
|
||
assert!(c2.unique);
|
||
assert_eq!(c2.name, None);
|
||
}
|
||
|
||
#[test]
|
||
fn if_not_exists_sets_the_flag() {
|
||
let c = ci("create index if not exists ix on Customers (email)");
|
||
assert!(c.if_not_exists);
|
||
assert_eq!(c.name.as_deref(), Some("ix"));
|
||
// combined with unique + unnamed + trailing semicolon
|
||
let c2 = ci("create unique index if not exists on Customers (email);");
|
||
assert!(c2.unique && c2.if_not_exists);
|
||
assert_eq!(c2.name, None);
|
||
}
|
||
|
||
#[test]
|
||
fn multi_column_index_parses() {
|
||
let c = ci("create index on Orders (CustId, Date)");
|
||
assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
|
||
// `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
|
||
// SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
|
||
assert!(matches!(
|
||
parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
|
||
.expect("parses"),
|
||
Command::SqlCreateTable { .. }
|
||
));
|
||
assert!(matches!(
|
||
parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
|
||
Command::SqlCreateIndex { .. }
|
||
));
|
||
assert!(matches!(
|
||
parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
|
||
.expect("parses"),
|
||
Command::SqlCreateIndex { unique: true, .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn simple_create_table_dsl_still_parses_in_advanced_mode() {
|
||
// The `create table … with pk …` DSL form falls back to the
|
||
// simple node even with two advanced `create` nodes present.
|
||
assert!(matches!(
|
||
parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
|
||
.expect("parses"),
|
||
Command::CreateTable { .. }
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sql_alter_table_tests {
|
||
use crate::dsl::command::{AlterTableAction, ColumnSpec, Command, TableConstraint};
|
||
use crate::dsl::parser::parse_command_in_mode;
|
||
use crate::mode::Mode;
|
||
|
||
fn alter(input: &str) -> (String, AlterTableAction) {
|
||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||
Command::SqlAlterTable { table, action } => (table, action),
|
||
other => panic!("expected SqlAlterTable, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn added_spec(input: &str) -> ColumnSpec {
|
||
match alter(input).1 {
|
||
AlterTableAction::AddColumn(spec) => *spec,
|
||
other => panic!("expected AddColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_plain() {
|
||
let (table, action) = alter("alter table T add column note text");
|
||
assert_eq!(table, "T");
|
||
match action {
|
||
AlterTableAction::AddColumn(spec) => {
|
||
assert_eq!(spec.name, "note");
|
||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||
assert!(!spec.not_null && !spec.unique);
|
||
assert!(spec.default_sql.is_none() && spec.check_sql.is_none());
|
||
}
|
||
other => panic!("expected AddColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_with_not_null_and_unique() {
|
||
let spec = added_spec("alter table T add column code text not null unique");
|
||
assert!(spec.not_null && spec.unique);
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_with_default_and_check_capture_raw_text() {
|
||
// DEFAULT / CHECK are captured as raw SQL text (sql_expr is
|
||
// validate-only) — ADR-0035 §4e.
|
||
let spec = added_spec("alter table T add column qty int default 0 check (qty >= 0)");
|
||
assert_eq!(spec.default_sql.as_deref(), Some("0"));
|
||
assert_eq!(spec.check_sql.as_deref(), Some("qty >= 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn add_column_accepts_sql_type_alias() {
|
||
// `varchar(255)` → text, length discarded (ADR-0035 §3).
|
||
let spec = added_spec("alter table T add column name varchar(255)");
|
||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||
}
|
||
|
||
#[test]
|
||
fn drop_column() {
|
||
match alter("alter table T drop column note").1 {
|
||
AlterTableAction::DropColumn { column } => assert_eq!(column, "note"),
|
||
other => panic!("expected DropColumn, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rename_column() {
|
||
match alter("alter table T rename column a to b").1 {
|
||
AlterTableAction::RenameColumn { old, new } => {
|
||
assert_eq!(old, "a");
|
||
assert_eq!(new, "b");
|
||
}
|
||
other => panic!("expected RenameColumn, got {other:?}"),
|
||
}
|
||
// trailing semicolon tolerated
|
||
assert!(matches!(
|
||
alter("alter table T rename column a to b;").1,
|
||
AlterTableAction::RenameColumn { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rename_table() {
|
||
// ADR-0035 §6 / 4h: `rename to <new>` — the `rename` verb fans out
|
||
// on a distinct second keyword (`to` vs `column`).
|
||
let (table, action) = alter("alter table Orders rename to Purchases");
|
||
assert_eq!(table, "Orders");
|
||
match action {
|
||
AlterTableAction::RenameTable { new } => assert_eq!(new, "Purchases"),
|
||
other => panic!("expected RenameTable, got {other:?}"),
|
||
}
|
||
// trailing semicolon tolerated
|
||
assert!(matches!(
|
||
alter("alter table Orders rename to Purchases;").1,
|
||
AlterTableAction::RenameTable { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rename_table_does_not_steal_rename_column() {
|
||
// The two `rename` tails coexist: `rename to` → table,
|
||
// `rename column … to …` → column. Neither misroutes.
|
||
assert!(matches!(
|
||
alter("alter table T rename to U").1,
|
||
AlterTableAction::RenameTable { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T rename column a to b").1,
|
||
AlterTableAction::RenameColumn { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn rename_to_internal_target_refused_at_parse() {
|
||
// The target slot carries the `reject_internal_table` validator
|
||
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
|
||
// before submit — engine-neutral, not a raw engine error.
|
||
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_type_parses() {
|
||
// ADR-0035 §4f: the fourth action, discriminated by the `type`
|
||
// keyword (ADD COLUMN's type is an ident, not the literal word).
|
||
let (table, action) = alter("alter table T alter column qty type int");
|
||
assert_eq!(table, "T");
|
||
match action {
|
||
AlterTableAction::AlterColumnType { column, ty } => {
|
||
assert_eq!(column, "qty");
|
||
assert_eq!(ty, crate::dsl::types::Type::Int);
|
||
}
|
||
other => panic!("expected AlterColumnType, got {other:?}"),
|
||
}
|
||
// trailing semicolon tolerated
|
||
assert!(matches!(
|
||
alter("alter table T alter column qty type int;").1,
|
||
AlterTableAction::AlterColumnType { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_type_accepts_sql_type_alias() {
|
||
// `integer` → int, `double precision` → real (ADR-0035 §3),
|
||
// reusing SQL_TYPE for the type slot.
|
||
match alter("alter table T alter column n type integer").1 {
|
||
AlterTableAction::AlterColumnType { ty, .. } => {
|
||
assert_eq!(ty, crate::dsl::types::Type::Int);
|
||
}
|
||
other => panic!("expected AlterColumnType, got {other:?}"),
|
||
}
|
||
match alter("alter table T alter column n type double precision").1 {
|
||
AlterTableAction::AlterColumnType { ty, .. } => {
|
||
assert_eq!(ty, crate::dsl::types::Type::Real);
|
||
}
|
||
other => panic!("expected AlterColumnType, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn four_branch_dispatch_still_routes_the_column_actions() {
|
||
// The new `alter column type` branch does not steal add/drop/
|
||
// rename: each still routes to its own action.
|
||
assert!(matches!(
|
||
alter("alter table T add column note text").1,
|
||
AlterTableAction::AddColumn(_)
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T drop column note").1,
|
||
AlterTableAction::DropColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T rename column a to b").1,
|
||
AlterTableAction::RenameColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T alter column a type text").1,
|
||
AlterTableAction::AlterColumnType { .. }
|
||
));
|
||
}
|
||
|
||
// --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill ---
|
||
|
||
#[test]
|
||
fn alter_column_set_data_type_is_a_type_synonym() {
|
||
// ISO `SET DATA TYPE` is the canonical form; it yields the same
|
||
// AlterColumnType action as the PostgreSQL `TYPE` shorthand.
|
||
match alter("alter table T alter column qty set data type int").1 {
|
||
AlterTableAction::AlterColumnType { column, ty } => {
|
||
assert_eq!(column, "qty");
|
||
assert_eq!(ty, crate::dsl::types::Type::Int);
|
||
}
|
||
other => panic!("expected AlterColumnType, got {other:?}"),
|
||
}
|
||
// alias map still applies through the synonym
|
||
assert!(matches!(
|
||
alter("alter table T alter column n set data type double precision").1,
|
||
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_set_not_null_parses() {
|
||
let (table, action) = alter("alter table T alter column email set not null");
|
||
assert_eq!(table, "T");
|
||
match action {
|
||
AlterTableAction::SetColumnNotNull { column } => assert_eq!(column, "email"),
|
||
other => panic!("expected SetColumnNotNull, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_drop_not_null_parses() {
|
||
match alter("alter table T alter column email drop not null").1 {
|
||
AlterTableAction::DropColumnNotNull { column } => assert_eq!(column, "email"),
|
||
other => panic!("expected DropColumnNotNull, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_set_default_captures_raw_expr() {
|
||
match alter("alter table T alter column qty set default 0").1 {
|
||
AlterTableAction::SetColumnDefault { column, default_sql } => {
|
||
assert_eq!(column, "qty");
|
||
assert_eq!(default_sql, "0");
|
||
}
|
||
other => panic!("expected SetColumnDefault, got {other:?}"),
|
||
}
|
||
// a parenthesised expression default round-trips as raw text
|
||
match alter("alter table T alter column qty set default (1 + 1)").1 {
|
||
AlterTableAction::SetColumnDefault { default_sql, .. } => {
|
||
assert_eq!(default_sql, "(1 + 1)");
|
||
}
|
||
other => panic!("expected SetColumnDefault, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_drop_default_parses() {
|
||
match alter("alter table T alter column qty drop default").1 {
|
||
AlterTableAction::DropColumnDefault { column } => assert_eq!(column, "qty"),
|
||
other => panic!("expected DropColumnDefault, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alter_column_gap_fill_does_not_steal_the_existing_actions() {
|
||
// The new set/drop column-attribute forms must not misroute the
|
||
// top-level add/drop/rename-column or the bare `type` form.
|
||
assert!(matches!(
|
||
alter("alter table T add column note text not null").1,
|
||
AlterTableAction::AddColumn(_)
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T drop column note").1,
|
||
AlterTableAction::DropColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T alter column a type text").1,
|
||
AlterTableAction::AlterColumnType { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T drop constraint c_chk").1,
|
||
AlterTableAction::DropConstraint { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn type_discriminator_probe_column_named_type() {
|
||
// PROBE (DA): the `type`-keyword discriminator keys on the literal
|
||
// `type` *keyword* node, which only the ALTER COLUMN TYPE branch
|
||
// carries. Verify a column whose *name* is `type` does not get
|
||
// misrouted (it is an ident, not a Word). If `type` is reserved
|
||
// and rejected as an ident, the parse errors — either outcome is
|
||
// fine; the failure we guard against is silent misrouting to
|
||
// AlterColumnType (which would then error on a missing type).
|
||
let dropped = parse_command_in_mode("alter table T drop column type", Mode::Advanced);
|
||
match dropped {
|
||
Ok(Command::SqlAlterTable {
|
||
action: AlterTableAction::DropColumn { column },
|
||
..
|
||
}) => assert_eq!(column, "type", "a column named `type` drops correctly"),
|
||
Ok(other) => panic!("`drop column type` misrouted to {other:?}"),
|
||
Err(_) => { /* `type` rejected as an ident — acceptable, no misroute */ }
|
||
}
|
||
// And the real ALTER COLUMN TYPE still routes (sanity).
|
||
assert!(matches!(
|
||
parse_command_in_mode("alter table T alter column c type int", Mode::Advanced),
|
||
Ok(Command::SqlAlterTable {
|
||
action: AlterTableAction::AlterColumnType { .. },
|
||
..
|
||
})
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn add_table_check_unnamed_and_named() {
|
||
// ADR-0035 §4g: table-level CHECK, unnamed and named.
|
||
match alter("alter table T add check (a < b)").1 {
|
||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||
assert_eq!(name, None);
|
||
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
|
||
}
|
||
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
|
||
}
|
||
match alter("alter table T add constraint a_lt_b check (a < b)").1 {
|
||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||
assert_eq!(name.as_deref(), Some("a_lt_b"));
|
||
assert!(matches!(*constraint, TableConstraint::Check { .. }));
|
||
}
|
||
other => panic!("expected named AddTableConstraint/Check, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn add_composite_unique() {
|
||
match alter("alter table T add unique (a, b)").1 {
|
||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||
assert_eq!(name, None);
|
||
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
|
||
}
|
||
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn named_unique_is_refused() {
|
||
// §4g: composite UNIQUE is anonymous in our model — naming it is
|
||
// refused by the BUILDER (it parses, then the builder rejects),
|
||
// so the error is the friendly message, not a parse error.
|
||
let err = parse_command_in_mode(
|
||
"alter table T add constraint u unique (a, b)",
|
||
Mode::Advanced,
|
||
)
|
||
.expect_err("a named UNIQUE constraint is refused");
|
||
assert!(
|
||
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
|
||
"expected the builder's named-UNIQUE refusal, got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_primary_key_is_refused() {
|
||
// §4g: adding a PK to an existing table is refused by the BUILDER
|
||
// (it parses for a clean message, then the builder rejects it).
|
||
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
|
||
.expect_err("ADD PRIMARY KEY is refused");
|
||
assert!(
|
||
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
|
||
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_foreign_key_named_and_bare() {
|
||
// `add foreign key (col) references P(id)` and the bare
|
||
// `references P` form; named via the CONSTRAINT prefix.
|
||
match alter("alter table C add foreign key (pid) references P(id)").1 {
|
||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||
assert_eq!(name, None);
|
||
match *constraint {
|
||
TableConstraint::ForeignKey(fk) => {
|
||
assert_eq!(fk.child_columns, vec!["pid".to_string()]);
|
||
assert_eq!(fk.parent_table, "P");
|
||
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||
}
|
||
other => panic!("expected ForeignKey, got {other:?}"),
|
||
}
|
||
}
|
||
other => panic!("expected AddTableConstraint/FK, got {other:?}"),
|
||
}
|
||
match alter("alter table C add constraint fk_p foreign key (pid) references P").1 {
|
||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||
assert_eq!(name.as_deref(), Some("fk_p"));
|
||
match *constraint {
|
||
TableConstraint::ForeignKey(fk) => {
|
||
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
|
||
}
|
||
other => panic!("expected ForeignKey, got {other:?}"),
|
||
}
|
||
}
|
||
other => panic!("expected named AddTableConstraint/FK, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn drop_constraint_by_name() {
|
||
match alter("alter table T drop constraint a_lt_b").1 {
|
||
AlterTableAction::DropConstraint { name } => assert_eq!(name, "a_lt_b"),
|
||
other => panic!("expected DropConstraint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn six_branch_dispatch_still_routes_column_actions() {
|
||
// The two new add/drop-constraint branches do not steal the four
|
||
// column actions.
|
||
assert!(matches!(
|
||
alter("alter table T add column note text").1,
|
||
AlterTableAction::AddColumn(_)
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T add column code text unique").1,
|
||
AlterTableAction::AddColumn(_),
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T drop column note").1,
|
||
AlterTableAction::DropColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T rename column a to b").1,
|
||
AlterTableAction::RenameColumn { .. }
|
||
));
|
||
assert!(matches!(
|
||
alter("alter table T alter column a type text").1,
|
||
AlterTableAction::AlterColumnType { .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn alter_is_advanced_only() {
|
||
// No simple `alter`; in simple mode it does not parse as a
|
||
// command (the dispatcher emits the "this is SQL" hint).
|
||
assert!(parse_command_in_mode("alter table T drop column c", Mode::Simple).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn internal_table_is_rejected_at_parse() {
|
||
// The ALTER table slot carries `reject_internal_table`.
|
||
assert!(
|
||
parse_command_in_mode(
|
||
"alter table __rdbms_playground_columns drop column table_name",
|
||
Mode::Advanced
|
||
)
|
||
.is_err()
|
||
);
|
||
}
|
||
}
|