feat: ADR-0035 4e — ALTER TABLE add/drop/rename column
Advanced-only `alter` entry word; ALTER TABLE <T> ADD COLUMN <col> <type> [constraints] | DROP COLUMN <col> | RENAME COLUMN <old> TO <new> -> SqlAlterTable, runtime-decomposed to the existing column executors (do_add_column / do_drop_column / do_rename_column) — one undo step each, no new worker layer. The COLUMN keyword is required (reserves bare RENAME TO for 4h, ADD CONSTRAINT for 4g). - ADD COLUMN takes NOT NULL / UNIQUE / DEFAULT / CHECK (no PK / inline REFERENCES). do_add_column extended to consume the SQL raw-text default_sql / check_sql (sql_expr is validate-only, the 4a.2 mechanism), reaching parity with CREATE TABLE's column constraints. - Drop/rename column refuse a column any CHECK references — table-level AND column-level (incl. a column's own self-check on rename) — the 4a.3 deferral, detected up-front by tokenizing the raw CHECK text (skipping string literals). In the shared executors, so it guards both the simple and SQL surfaces and fixes a latent rename-drift bug that desynced the stored CHECK text and broke rebuild. - SQL DROP COLUMN refuses an index-covered column (no --cascade SQL spelling — matches SQLite + the simple default). - The column executors and do_add_index gained an internal-__rdbms_* guard (refuse as "no such table"), closing a pre-existing exposure on both surfaces. (do_change_column_type / do_add_constraint / do_add_relationship are a tracked follow-up.) - `alter` is advanced-only; AlterTableAction::AddColumn is boxed (clippy::large_enum_variant). Docs: ADR-0035 status + §13 4e; ADR README; requirements.md Q1. Plan: docs/plans/20260525-adr-0035-sql-ddl-4e.md. Tests: 1854 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
+279
-2
@@ -13,8 +13,8 @@
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector,
|
||||
RelationshipSelector, SqlForeignKey,
|
||||
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
||||
IndexSelector, RelationshipSelector, SqlForeignKey,
|
||||
};
|
||||
use crate::dsl::value::Value;
|
||||
use crate::dsl::grammar::{
|
||||
@@ -1841,6 +1841,182 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// SQL `ALTER TABLE <T> <action>` (ADR-0035 §4, sub-phase 4e).
|
||||
// `alter` is an advanced-*only* entry word (like `select`/`with`).
|
||||
// Actions: ADD/DROP/RENAME COLUMN — the `COLUMN` keyword is required
|
||||
// (reserves bare `RENAME TO` for 4h and `ADD CONSTRAINT` for 4g).
|
||||
// =================================================================
|
||||
|
||||
// The ALTER table slot carries the SQL-family `reject_internal_table`
|
||||
// validator (parse-time refusal; the executors guard the rest) and
|
||||
// `writes_table` so the DROP/RENAME column slot narrows to its columns.
|
||||
const AT_TABLE_NAME: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "table_name",
|
||||
validator: Some(super::sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
// ADD COLUMN's constraint suffix — the SQL leaf nodes for NOT NULL /
|
||||
// UNIQUE / DEFAULT / CHECK only. PK and inline REFERENCES are
|
||||
// deliberately excluded (PK is invalid on ADD COLUMN; REFERENCES is 4g).
|
||||
static AT_ADD_CONSTRAINT_CHOICES: &[Node] = &[
|
||||
Node::Seq(super::sql_create_table::NOT_NULL_NODES),
|
||||
Node::Word(Word::keyword("unique")),
|
||||
Node::Seq(super::sql_create_table::DEFAULT_NODES),
|
||||
Node::Seq(super::sql_create_table::CHECK_NODES),
|
||||
];
|
||||
const AT_ADD_CONSTRAINT: Node = Node::Choice(AT_ADD_CONSTRAINT_CHOICES);
|
||||
const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||||
inner: &AT_ADD_CONSTRAINT,
|
||||
separator: None,
|
||||
min: 0,
|
||||
};
|
||||
|
||||
static AT_ADD_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("add")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
super::sql_create_table::COL_NAME,
|
||||
super::sql_create_table::SQL_TYPE,
|
||||
AT_ADD_CONSTRAINT_SUFFIX,
|
||||
];
|
||||
const AT_ADD_COLUMN: Node = Node::Seq(AT_ADD_COLUMN_NODES);
|
||||
|
||||
static AT_DROP_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("drop")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
COLUMN_NAME,
|
||||
];
|
||||
const AT_DROP_COLUMN: Node = Node::Seq(AT_DROP_COLUMN_NODES);
|
||||
|
||||
static AT_RENAME_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("rename")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
COLUMN_NAME,
|
||||
Node::Word(Word::keyword("to")),
|
||||
NEW_COLUMN_NAME,
|
||||
];
|
||||
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
||||
|
||||
// Each action branch leads on a concrete keyword (`add`/`drop`/
|
||||
// `rename`) — trap-safe.
|
||||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_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())],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build `Command::SqlAlterTable` (ADR-0035 §4e). The action is the
|
||||
/// leading concrete keyword (`add`/`drop`/`rename` — exactly one matches
|
||||
/// per the action `Choice`).
|
||||
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("add") {
|
||||
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
|
||||
} else if path.contains_word("rename") {
|
||||
AlterTableAction::RenameColumn {
|
||||
old: require_ident(path, "column_name")?,
|
||||
new: require_ident(path, "new_column_name")?,
|
||||
}
|
||||
} else {
|
||||
AlterTableAction::DropColumn {
|
||||
column: require_ident(path, "column_name")?,
|
||||
}
|
||||
};
|
||||
Ok(Command::SqlAlterTable { table, action })
|
||||
}
|
||||
|
||||
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("alter"),
|
||||
shape: SQL_ALTER_TABLE_SHAPE,
|
||||
ast_builder: build_sql_alter_table,
|
||||
help_id: Some("ddl.sql_alter_table"),
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||||
// =================================================================
|
||||
@@ -2293,3 +2469,104 @@ mod sql_create_index_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sql_alter_table_tests {
|
||||
use crate::dsl::command::{AlterTableAction, ColumnSpec, Command};
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
fn alter(input: &str) -> (String, AlterTableAction) {
|
||||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||
Command::SqlAlterTable { table, action } => (table, action),
|
||||
other => panic!("expected SqlAlterTable, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn added_spec(input: &str) -> ColumnSpec {
|
||||
match alter(input).1 {
|
||||
AlterTableAction::AddColumn(spec) => *spec,
|
||||
other => panic!("expected AddColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_plain() {
|
||||
let (table, action) = alter("alter table T add column note text");
|
||||
assert_eq!(table, "T");
|
||||
match action {
|
||||
AlterTableAction::AddColumn(spec) => {
|
||||
assert_eq!(spec.name, "note");
|
||||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||||
assert!(!spec.not_null && !spec.unique);
|
||||
assert!(spec.default_sql.is_none() && spec.check_sql.is_none());
|
||||
}
|
||||
other => panic!("expected AddColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_not_null_and_unique() {
|
||||
let spec = added_spec("alter table T add column code text not null unique");
|
||||
assert!(spec.not_null && spec.unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_default_and_check_capture_raw_text() {
|
||||
// DEFAULT / CHECK are captured as raw SQL text (sql_expr is
|
||||
// validate-only) — ADR-0035 §4e.
|
||||
let spec = added_spec("alter table T add column qty int default 0 check (qty >= 0)");
|
||||
assert_eq!(spec.default_sql.as_deref(), Some("0"));
|
||||
assert_eq!(spec.check_sql.as_deref(), Some("qty >= 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_accepts_sql_type_alias() {
|
||||
// `varchar(255)` → text, length discarded (ADR-0035 §3).
|
||||
let spec = added_spec("alter table T add column name varchar(255)");
|
||||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_column() {
|
||||
match alter("alter table T drop column note").1 {
|
||||
AlterTableAction::DropColumn { column } => assert_eq!(column, "note"),
|
||||
other => panic!("expected DropColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_column() {
|
||||
match alter("alter table T rename column a to b").1 {
|
||||
AlterTableAction::RenameColumn { old, new } => {
|
||||
assert_eq!(old, "a");
|
||||
assert_eq!(new, "b");
|
||||
}
|
||||
other => panic!("expected RenameColumn, got {other:?}"),
|
||||
}
|
||||
// trailing semicolon tolerated
|
||||
assert!(matches!(
|
||||
alter("alter table T rename column a to b;").1,
|
||||
AlterTableAction::RenameColumn { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alter_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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
// `create [unique] index …` → SQL_CREATE_INDEX).
|
||||
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
||||
(&ddl::SQL_CREATE_INDEX, CommandCategory::Advanced),
|
||||
// `alter` is a new advanced-*only* DDL entry word (ADR-0035 §2/§4e),
|
||||
// like `select`/`with` — no simple node, so `is_advanced_only` is
|
||||
// true and simple-mode `alter …` gets the "this is SQL" hint.
|
||||
(&ddl::SQL_ALTER_TABLE, CommandCategory::Advanced),
|
||||
// Shared `drop` entry word: `ddl::DROP` (simple) and these advanced
|
||||
// SQL nodes. SQL-first in advanced mode; `drop table [if exists] T`
|
||||
// → SQL_DROP_TABLE, `drop index [if exists] <name>` → SQL_DROP_INDEX
|
||||
|
||||
@@ -95,11 +95,11 @@ static SQL_TYPE_CHOICES: &[Node] = &[
|
||||
Node::Seq(TYPE_WITH_LENGTH_NODES),
|
||||
];
|
||||
/// `double precision | <type-keyword-or-alias> [ '(' n [, n] ')' ]`.
|
||||
const SQL_TYPE: Node = Node::Choice(SQL_TYPE_CHOICES);
|
||||
pub(crate) const SQL_TYPE: Node = Node::Choice(SQL_TYPE_CHOICES);
|
||||
|
||||
// --- Column-level constraints (4a clean-reuse set only) -----------
|
||||
|
||||
static NOT_NULL_NODES: &[Node] = &[
|
||||
pub(crate) static NOT_NULL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Word(Word::keyword("null")),
|
||||
];
|
||||
@@ -132,8 +132,8 @@ static DEFAULT_VALUE_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("false")),
|
||||
];
|
||||
const DEFAULT_VALUE: Node = Node::Choice(DEFAULT_VALUE_CHOICES);
|
||||
static DEFAULT_NODES: &[Node] = &[Node::Word(Word::keyword("default")), DEFAULT_VALUE];
|
||||
static CHECK_NODES: &[Node] = &[
|
||||
pub(crate) static DEFAULT_NODES: &[Node] = &[Node::Word(Word::keyword("default")), DEFAULT_VALUE];
|
||||
pub(crate) static CHECK_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("check")),
|
||||
Node::Punct('('),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
@@ -217,7 +217,7 @@ const COL_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||||
|
||||
// --- Column definition: `<name> <type> [constraints…]` ------------
|
||||
|
||||
const COL_NAME: Node = Node::Ident {
|
||||
pub(crate) const COL_NAME: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "col_name",
|
||||
validator: None,
|
||||
|
||||
Reference in New Issue
Block a user