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:
claude@clouddev1
2026-05-25 19:49:13 +00:00
parent 701217d29f
commit bbc2e34b33
17 changed files with 1294 additions and 55 deletions
+279 -2
View File
@@ -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()
);
}
}
+4
View File
@@ -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
+5 -5
View File
@@ -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,