feat: ADR-0035 4f — ALTER TABLE … ALTER COLUMN TYPE

Fourth AlterTableAction (AlterColumnType), runtime-decomposed to the
existing change_column_type executor with ForceConversion — which IS the
§7 advanced policy: lossy converts with a note (no force flag),
incompatible + the ADR-0017 static refusals (↔blob, same-type,
date↔datetime, non-int→serial) still refuse, while int→serial is allowed
(auto-fills nulls + UNIQUE, ADR-0018 §8). No new mode/note/persistence;
undo is the advanced safety net.

Grammar adds a fourth action branch leading on `alter`, discriminated in
the builder by the `type` keyword (unique — ADD COLUMN's type is an
ident); the type slot reuses SQL_TYPE. The internal-__rdbms_* guard was
folded into do_change_column_type (user-confirmed), closing the simple
`change column` exposure.

Tests: 7 Tier-3 e2e via run_replay + 4 Tier-1 parse (incl. a column-named-
`type` discriminator probe) + the simple-surface guard. Help/usage
refreshed; ADR-0035 §13 4f + README + requirements.md in lockstep.
This commit is contained in:
claude@clouddev1
2026-05-25 21:16:37 +00:00
parent a2fc3c9e57
commit 5b76315d1e
11 changed files with 479 additions and 36 deletions
+152 -7
View File
@@ -1905,9 +1905,24 @@ static AT_RENAME_COLUMN_NODES: &[Node] = &[
];
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];
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
// TABLE / ADD COLUMN forms use). The builder keys on the `type` keyword
// — unique to this action (ADD COLUMN's type is a `col_type` ident).
static AT_ALTER_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("alter")),
Node::Word(Word::keyword("column")),
COLUMN_NAME,
Node::Word(Word::keyword("type")),
super::sql_create_table::SQL_TYPE,
];
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
// Each action branch leads on a concrete keyword (`add`/`drop`/`rename`/
// `alter`) — trap-safe. (The branch's `alter` is the action word; the
// entry-word `alter` was already consumed by dispatch.)
static AT_ACTION_CHOICES: &[Node] =
&[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN, AT_ALTER_COLUMN];
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
@@ -1989,12 +2004,54 @@ fn build_alter_add_column_spec(
})
}
/// Build `Command::SqlAlterTable` (ADR-0035 §4e). The action is the
/// leading concrete keyword (`add`/`drop`/`rename` — exactly one matches
/// per the action `Choice`).
/// Extract the `ALTER COLUMN <col> TYPE <type>` action (ADR-0035 §4f).
/// The type slot reuses SQL_TYPE, so the target-type extraction mirrors
/// `build_alter_add_column_spec`'s: a `col_type` ident via
/// `Type::from_sql_name` (alias map applied), or the two-word
/// `double precision` → `Type::Real`.
fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, ValidationError> {
let column = require_ident(path, "column_name")?;
let mut ty: Option<Type> = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
}
MatchedKind::Word("double") => {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("precision"))
) {
items.next();
}
ty = Some(Type::Real);
}
_ => {}
}
}
let ty = ty.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "alter column needs a target type".to_string())],
})?;
Ok(AlterTableAction::AlterColumnType { column, ty })
}
/// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f). The action is the
/// leading concrete keyword (`add`/`drop`/`rename`/`alter` — exactly one
/// matches per the action `Choice`). The `type` keyword is checked
/// **first**: it is unique to ALTER COLUMN TYPE (ADD COLUMN's type is a
/// `col_type` *ident*, not the literal word), and an `alter column …`
/// input contains none of add/drop/rename, so without this it would fall
/// through to the DropColumn arm.
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?;
let action = if path.contains_word("add") {
let action = if path.contains_word("type") {
build_alter_column_type(path)?
} else if path.contains_word("add") {
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
} else if path.contains_word("rename") {
AlterTableAction::RenameColumn {
@@ -2551,6 +2608,94 @@ mod sql_alter_table_tests {
));
}
#[test]
fn alter_column_type_parses() {
// ADR-0035 §4f: the fourth action, discriminated by the `type`
// keyword (ADD COLUMN's type is an ident, not the literal word).
let (table, action) = alter("alter table T alter column qty type int");
assert_eq!(table, "T");
match action {
AlterTableAction::AlterColumnType { column, ty } => {
assert_eq!(column, "qty");
assert_eq!(ty, crate::dsl::types::Type::Int);
}
other => panic!("expected AlterColumnType, got {other:?}"),
}
// trailing semicolon tolerated
assert!(matches!(
alter("alter table T alter column qty type int;").1,
AlterTableAction::AlterColumnType { .. }
));
}
#[test]
fn alter_column_type_accepts_sql_type_alias() {
// `integer` → int, `double precision` → real (ADR-0035 §3),
// reusing SQL_TYPE for the type slot.
match alter("alter table T alter column n type integer").1 {
AlterTableAction::AlterColumnType { ty, .. } => {
assert_eq!(ty, crate::dsl::types::Type::Int);
}
other => panic!("expected AlterColumnType, got {other:?}"),
}
match alter("alter table T alter column n type double precision").1 {
AlterTableAction::AlterColumnType { ty, .. } => {
assert_eq!(ty, crate::dsl::types::Type::Real);
}
other => panic!("expected AlterColumnType, got {other:?}"),
}
}
#[test]
fn four_branch_dispatch_still_routes_the_column_actions() {
// The new `alter column type` branch does not steal add/drop/
// rename: each still routes to its own action.
assert!(matches!(
alter("alter table T add column note text").1,
AlterTableAction::AddColumn(_)
));
assert!(matches!(
alter("alter table T drop column note").1,
AlterTableAction::DropColumn { .. }
));
assert!(matches!(
alter("alter table T rename column a to b").1,
AlterTableAction::RenameColumn { .. }
));
assert!(matches!(
alter("alter table T alter column a type text").1,
AlterTableAction::AlterColumnType { .. }
));
}
#[test]
fn type_discriminator_probe_column_named_type() {
// PROBE (DA): the `type`-keyword discriminator keys on the literal
// `type` *keyword* node, which only the ALTER COLUMN TYPE branch
// carries. Verify a column whose *name* is `type` does not get
// misrouted (it is an ident, not a Word). If `type` is reserved
// and rejected as an ident, the parse errors — either outcome is
// fine; the failure we guard against is silent misrouting to
// AlterColumnType (which would then error on a missing type).
let dropped = parse_command_in_mode("alter table T drop column type", Mode::Advanced);
match dropped {
Ok(Command::SqlAlterTable {
action: AlterTableAction::DropColumn { column },
..
}) => assert_eq!(column, "type", "a column named `type` drops correctly"),
Ok(other) => panic!("`drop column type` misrouted to {other:?}"),
Err(_) => { /* `type` rejected as an ident — acceptable, no misroute */ }
}
// And the real ALTER COLUMN TYPE still routes (sanity).
assert!(matches!(
parse_command_in_mode("alter table T alter column c type int", Mode::Advanced),
Ok(Command::SqlAlterTable {
action: AlterTableAction::AlterColumnType { .. },
..
})
));
}
#[test]
fn alter_is_advanced_only() {
// No simple `alter`; in simple mode it does not parse as a