5b76315d1e
Fourth AlterTableAction (AlterColumnType), runtime-decomposed to the existing change_column_type executor with ForceConversion — which IS the §7 advanced policy: lossy converts with a note (no force flag), incompatible + the ADR-0017 static refusals (↔blob, same-type, date↔datetime, non-int→serial) still refuse, while int→serial is allowed (auto-fills nulls + UNIQUE, ADR-0018 §8). No new mode/note/persistence; undo is the advanced safety net. Grammar adds a fourth action branch leading on `alter`, discriminated in the builder by the `type` keyword (unique — ADD COLUMN's type is an ident); the type slot reuses SQL_TYPE. The internal-__rdbms_* guard was folded into do_change_column_type (user-confirmed), closing the simple `change column` exposure. Tests: 7 Tier-3 e2e via run_replay + 4 Tier-1 parse (incl. a column-named- `type` discriminator probe) + the simple-surface guard. Help/usage refreshed; ADR-0035 §13 4f + README + requirements.md in lockstep.
2718 lines
101 KiB
Rust
2718 lines
101 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,
|
|
};
|
|
use crate::dsl::value::Value;
|
|
use crate::dsl::grammar::{
|
|
CommandNode, 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).
|
|
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('.'),
|
|
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 AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
|
|
|
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('.'),
|
|
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: 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")));
|
|
|
|
Ok(Command::AddRelationship {
|
|
name: ident(path, "relationship_name").map(str::to_string),
|
|
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")?,
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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: None,
|
|
writes_table: false,
|
|
writes_column: false,
|
|
writes_user_listed_column: false,
|
|
writes_table_alias: false,
|
|
writes_cte_name: false,
|
|
writes_projection_alias: false,
|
|
},
|
|
Node::Punct(')'),
|
|
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"),
|
|
usage_ids: &["parse.usage.create_table"],};
|
|
|
|
/// 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") => {
|
|
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
|
|
foreign_keys.push(consume_fk_reference(&mut items, None, child_column));
|
|
}
|
|
// 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> )`
|
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
|
items.next();
|
|
}
|
|
let child_column = items.next().map_or_else(String::new, |it| it.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_column);
|
|
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_column: String,
|
|
) -> 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> )`.
|
|
let mut parent_column = None;
|
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
|
items.next(); // `(`
|
|
if let Some(it) = items.next() {
|
|
parent_column = Some(it.text.clone());
|
|
}
|
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
|
items.next(); // `)`
|
|
}
|
|
}
|
|
// `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_column,
|
|
parent_table,
|
|
parent_column,
|
|
on_delete,
|
|
on_update,
|
|
}
|
|
}
|
|
|
|
/// 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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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,
|
|
};
|
|
|
|
static AT_ADD_COLUMN_NODES: &[Node] = &[
|
|
Node::Word(Word::keyword("add")),
|
|
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: Node = Node::Seq(AT_ADD_COLUMN_NODES);
|
|
|
|
static AT_DROP_COLUMN_NODES: &[Node] = &[
|
|
Node::Word(Word::keyword("drop")),
|
|
Node::Word(Word::keyword("column")),
|
|
COLUMN_NAME,
|
|
];
|
|
const AT_DROP_COLUMN: Node = Node::Seq(AT_DROP_COLUMN_NODES);
|
|
|
|
static AT_RENAME_COLUMN_NODES: &[Node] = &[
|
|
Node::Word(Word::keyword("rename")),
|
|
Node::Word(Word::keyword("column")),
|
|
COLUMN_NAME,
|
|
Node::Word(Word::keyword("to")),
|
|
NEW_COLUMN_NAME,
|
|
];
|
|
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
|
|
|
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
|
|
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
|
|
// TABLE / ADD COLUMN forms use). The builder keys on the `type` keyword
|
|
// — unique to this action (ADD COLUMN's type is a `col_type` ident).
|
|
static AT_ALTER_COLUMN_NODES: &[Node] = &[
|
|
Node::Word(Word::keyword("alter")),
|
|
Node::Word(Word::keyword("column")),
|
|
COLUMN_NAME,
|
|
Node::Word(Word::keyword("type")),
|
|
super::sql_create_table::SQL_TYPE,
|
|
];
|
|
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
|
|
|
|
// Each action branch leads on a concrete keyword (`add`/`drop`/`rename`/
|
|
// `alter`) — trap-safe. (The branch's `alter` is the action word; the
|
|
// entry-word `alter` was already consumed by dispatch.)
|
|
static AT_ACTION_CHOICES: &[Node] =
|
|
&[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN, 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 `Command::SqlAlterTable` (ADR-0035 §4e/§4f). The action is the
|
|
/// leading concrete keyword (`add`/`drop`/`rename`/`alter` — exactly one
|
|
/// matches per the action `Choice`). The `type` keyword is checked
|
|
/// **first**: it is unique to ALTER COLUMN TYPE (ADD COLUMN's type is a
|
|
/// `col_type` *ident*, not the literal word), and an `alter column …`
|
|
/// input contains none of add/drop/rename, so without this it would fall
|
|
/// through to the DropColumn arm.
|
|
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") {
|
|
build_alter_column_type(path)?
|
|
} else 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")?,
|
|
}
|
|
};
|
|
Ok(Command::SqlAlterTable { table, action })
|
|
}
|
|
|
|
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"),
|
|
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};
|
|
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 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 { .. }
|
|
));
|
|
}
|
|
|
|
#[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 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()
|
|
);
|
|
}
|
|
}
|