Files
rdbms-playground/src/dsl/grammar/ddl.rs
T
claude@clouddev1 6985a43f31 fix(fk): inline FK referencing a compound PK points at the table-level form
ADR-0043 D4 residual: an inline column-level FK (`<col> REFERENCES P(a,b)`)
is single-column by construction, so referencing a parent's compound PK
gave the generic arity error ("1 foreign-key column(s) on the child side,
but `P`'s key has 2..."). It now points the user at the table-level form:
"an inline column reference can only name one column ... Use the table-level
form instead: FOREIGN KEY (<columns>) REFERENCES P (a, b)".

- Adds `inline: bool` to SqlForeignKey, set by the grammar's single shared
  builder consume_fk_reference (true for the inline path, false for the
  table-level and ALTER paths).
- resolve_fk_parent_columns takes `inline` and tailors the arity-mismatch
  message when an inline FK meets a compound key.

Tests: parse-layer (inline=true / table-level=false) + end-to-end worker
refusal wording. 2209 pass / 0 fail / 1 ignored. Clippy clean.
2026-06-10 11:49:33 +00:00

3362 lines
129 KiB
Rust
Raw Blame History

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