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:
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
Accepted. Design agreed with the user (2026-05-24); the approach is
|
Accepted. Design agreed with the user (2026-05-24); the approach is
|
||||||
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d /
|
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d /
|
||||||
4e** (`CREATE TABLE` with column- and table-level constraints and foreign
|
4e / 4f** (`CREATE TABLE` with column- and table-level constraints and
|
||||||
keys, `DROP TABLE [IF EXISTS]`, `CREATE [UNIQUE] INDEX` /
|
foreign keys, `DROP TABLE [IF EXISTS]`, `CREATE [UNIQUE] INDEX` /
|
||||||
`DROP INDEX [IF EXISTS]`, and `ALTER TABLE` add/drop/rename column,
|
`DROP INDEX [IF EXISTS]`, `ALTER TABLE` add/drop/rename column, and
|
||||||
implemented 2026-05-25 — plans
|
`ALTER TABLE … ALTER COLUMN TYPE`, implemented 2026-05-25 — plans
|
||||||
`docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`,
|
`docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`,
|
||||||
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`,
|
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`,
|
||||||
`…-4e.md`), so the decision is accepted while the remaining sub-phases
|
`…-4e.md`, `…-4f.md`), so the decision is accepted while the remaining
|
||||||
(**4f–4i**, §13) continue. This is **Phase 4** of the ADR-0030 roadmap (the
|
sub-phases (**4g–4i**, §13) continue. This is **Phase 4** of the ADR-0030 roadmap (the
|
||||||
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
|
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
|
||||||
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
|
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
|
||||||
on how DDL is represented and executed.
|
on how DDL is represented and executed.
|
||||||
@@ -436,7 +436,25 @@ ADR-0033's structure:
|
|||||||
internal-table guard on `do_change_column_type` / `do_add_constraint` /
|
internal-table guard on `do_change_column_type` / `do_add_constraint` /
|
||||||
`do_add_relationship` is a tracked follow-up.)*
|
`do_add_relationship` is a tracked follow-up.)*
|
||||||
- **4f — `ALTER TABLE … ALTER COLUMN TYPE`** (the §7 conversion
|
- **4f — `ALTER TABLE … ALTER COLUMN TYPE`** (the §7 conversion
|
||||||
model + the lossy-with-note path).
|
model + the lossy-with-note path). *(Implemented 2026-05-25 — plan
|
||||||
|
`docs/plans/20260525-adr-0035-sql-ddl-4f.md`.)* A fourth
|
||||||
|
`AlterTableAction::AlterColumnType`, runtime-decomposed to the existing
|
||||||
|
`change_column_type` executor with `ChangeColumnMode::ForceConversion`
|
||||||
|
— which **is** the §7 advanced policy: lossy cells are *performed* and
|
||||||
|
counted (the engine-neutral `client_side.transformed_lossy` note
|
||||||
|
fires), incompatible cells refuse, and the ADR-0017 static refusals
|
||||||
|
(`↔ blob`, same-type, `date ↔ datetime`, non-`int → serial`) refuse in
|
||||||
|
both modes. **`int → serial` is *allowed*** (auto-fills nulls, adds
|
||||||
|
UNIQUE if non-PK — ADR-0018 §8; the §7 "static-refused →serial"
|
||||||
|
summary is looser than the code). No force flag, no `USING`, no
|
||||||
|
`SET DATA TYPE` synonym (§7/§12); `undo` is the advanced safety net.
|
||||||
|
The 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 2026-05-25), closing the simple `change column`
|
||||||
|
exposure too. *(The remaining internal-table guard on
|
||||||
|
`do_add_constraint` / `do_add_relationship` rides in 4g.)*
|
||||||
- **4g — `ALTER TABLE` add/drop constraint, add foreign key.**
|
- **4g — `ALTER TABLE` add/drop constraint, add foreign key.**
|
||||||
- **4h — `ALTER TABLE … RENAME TO`** (the §6 new low-level op).
|
- **4h — `ALTER TABLE … RENAME TO`** (the §6 new low-level op).
|
||||||
- **4i — Verification sweep.** Typing-surface + matrix coverage,
|
- **4i — Verification sweep.** Typing-surface + matrix coverage,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -234,9 +234,13 @@ handoff-14 cleanup; 449 after B2/C2.)
|
|||||||
then `ALTER TABLE` add/drop/rename column (4e — `alter` is advanced-only,
|
then `ALTER TABLE` add/drop/rename column (4e — `alter` is advanced-only,
|
||||||
runtime-decomposed to the existing column executors; ADD COLUMN reaches
|
runtime-decomposed to the existing column executors; ADD COLUMN reaches
|
||||||
CREATE-TABLE constraint parity; drop/rename refuse a table-CHECK-
|
CREATE-TABLE constraint parity; drop/rename refuse a table-CHECK-
|
||||||
referenced column)).
|
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
|
||||||
Remaining DDL — `ALTER TABLE … ALTER COLUMN TYPE` / add-drop-constraint /
|
runtime-decomposed to `change_column_type` with `ForceConversion`, the
|
||||||
add-FK / `RENAME TO` (4f–4h) — is phased per ADR-0035 §13.)*
|
§7 advanced policy: lossy converts with a note, incompatible + static
|
||||||
|
refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed;
|
||||||
|
the internal-`__rdbms_*` guard folded into `do_change_column_type`)).
|
||||||
|
Remaining DDL — `ALTER TABLE` add-drop-constraint / add-FK / `RENAME TO`
|
||||||
|
(4g–4h) — is phased per ADR-0035 §13.)*
|
||||||
- [ ] **Q2** Non-standard syntax rejected with a clear message
|
- [ ] **Q2** Non-standard syntax rejected with a clear message
|
||||||
pointing at the supported subset.
|
pointing at the supported subset.
|
||||||
*(Design done — ADR-0030 §8: out-of-subset statements are
|
*(Design done — ADR-0030 §8: out-of-subset statements are
|
||||||
|
|||||||
@@ -1597,6 +1597,11 @@ impl App {
|
|||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
Some(old.as_str()),
|
Some(old.as_str()),
|
||||||
),
|
),
|
||||||
|
AlterTableAction::AlterColumnType { column, .. } => (
|
||||||
|
Operation::ChangeColumnType,
|
||||||
|
Some(table.as_str()),
|
||||||
|
Some(column.as_str()),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
C::SqlCreateTable { name, .. } => {
|
C::SqlCreateTable { name, .. } => {
|
||||||
(Operation::CreateTable, Some(name.as_str()), None)
|
(Operation::CreateTable, Some(name.as_str()), None)
|
||||||
|
|||||||
@@ -4279,6 +4279,11 @@ fn do_change_column_type(
|
|||||||
ty: Type,
|
ty: Type,
|
||||||
mode: ChangeColumnMode,
|
mode: ChangeColumnMode,
|
||||||
) -> Result<ChangeColumnTypeResult, DbError> {
|
) -> Result<ChangeColumnTypeResult, DbError> {
|
||||||
|
// Refuse the internal `__rdbms_*` tables up-front (as "no such
|
||||||
|
// table"), like the sibling column executors. Closes the simple
|
||||||
|
// `change column` exposure and the SQL `ALTER COLUMN TYPE`
|
||||||
|
// decomposition target (ADR-0035 §4f); user-confirmed 2026-05-25.
|
||||||
|
reject_internal_table_name(table)?;
|
||||||
let old_schema = read_schema(conn, table)?;
|
let old_schema = read_schema(conn, table)?;
|
||||||
let col_info = old_schema
|
let col_info = old_schema
|
||||||
.columns
|
.columns
|
||||||
|
|||||||
+11
-3
@@ -714,9 +714,11 @@ pub enum IndexSelector {
|
|||||||
Columns { table: String, columns: Vec<String> },
|
Columns { table: String, columns: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4). Sub-phase
|
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4).
|
||||||
/// 4e carries the column actions; 4f/4g/4h add `AlterColumnType`,
|
///
|
||||||
/// `AddConstraint`/`AddForeignKey`/`DropConstraint`, and `RenameTo`.
|
/// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`;
|
||||||
|
/// 4g/4h add `AddConstraint`/`AddForeignKey`/`DropConstraint`, and
|
||||||
|
/// `RenameTo`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AlterTableAction {
|
pub enum AlterTableAction {
|
||||||
/// `ADD COLUMN <name> <type> [NOT NULL] [UNIQUE] [DEFAULT …]
|
/// `ADD COLUMN <name> <type> [NOT NULL] [UNIQUE] [DEFAULT …]
|
||||||
@@ -731,6 +733,12 @@ pub enum AlterTableAction {
|
|||||||
DropColumn { column: String },
|
DropColumn { column: String },
|
||||||
/// `RENAME COLUMN <old> TO <new>` — reuses `do_rename_column`.
|
/// `RENAME COLUMN <old> TO <new>` — reuses `do_rename_column`.
|
||||||
RenameColumn { old: String, new: String },
|
RenameColumn { old: String, new: String },
|
||||||
|
/// `ALTER COLUMN <name> TYPE <type>` — reuses `do_change_column_type`
|
||||||
|
/// with `ChangeColumnMode::ForceConversion`, which is the ADR-0035 §7
|
||||||
|
/// advanced-mode policy (lossy cells are *performed* with a note, no
|
||||||
|
/// force flag; static-refused / incompatible still refuse). One undo
|
||||||
|
/// step (the executor's rebuild). ADR-0035 §4f.
|
||||||
|
AlterColumnType { column: String, ty: Type },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for IndexSelector {
|
impl std::fmt::Display for IndexSelector {
|
||||||
|
|||||||
+152
-7
@@ -1905,9 +1905,24 @@ static AT_RENAME_COLUMN_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
||||||
|
|
||||||
// Each action branch leads on a concrete keyword (`add`/`drop`/
|
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
|
||||||
// `rename`) — trap-safe.
|
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
|
||||||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN];
|
// 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);
|
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
|
||||||
|
|
||||||
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
|
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
|
/// Extract the `ALTER COLUMN <col> TYPE <type>` action (ADR-0035 §4f).
|
||||||
/// leading concrete keyword (`add`/`drop`/`rename` — exactly one matches
|
/// The type slot reuses SQL_TYPE, so the target-type extraction mirrors
|
||||||
/// per the action `Choice`).
|
/// `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> {
|
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||||
let table = require_ident(path, "table_name")?;
|
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)?))
|
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
|
||||||
} else if path.contains_word("rename") {
|
} else if path.contains_word("rename") {
|
||||||
AlterTableAction::RenameColumn {
|
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]
|
#[test]
|
||||||
fn alter_is_advanced_only() {
|
fn alter_is_advanced_only() {
|
||||||
// No simple `alter`; in simple mode it does not parse as a
|
// No simple `alter`; in simple mode it does not parse as a
|
||||||
|
|||||||
@@ -273,7 +273,8 @@ help:
|
|||||||
sql_alter_table: |-
|
sql_alter_table: |-
|
||||||
alter table <T> add column <col> <type> [not null] [unique] [default …] [check …]
|
alter table <T> add column <col> <type> [not null] [unique] [default …] [check …]
|
||||||
alter table <T> drop column <col>
|
alter table <T> drop column <col>
|
||||||
alter table <T> rename column <old> to <new> — change a table's columns (advanced SQL)
|
alter table <T> rename column <old> to <new>
|
||||||
|
alter table <T> alter column <col> type <type> — change a table's columns (advanced SQL)
|
||||||
drop: |-
|
drop: |-
|
||||||
drop table <T> — remove a table
|
drop table <T> — remove a table
|
||||||
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
||||||
@@ -473,6 +474,7 @@ parse:
|
|||||||
alter table <Table> add column <Name> <Type> [not null] [unique] [default <expr>] [check (<expr>)]
|
alter table <Table> add column <Name> <Type> [not null] [unique] [default <expr>] [check (<expr>)]
|
||||||
alter table <Table> drop column <Name>
|
alter table <Table> drop column <Name>
|
||||||
alter table <Table> rename column <Old> to <New>
|
alter table <Table> rename column <Old> to <New>
|
||||||
|
alter table <Table> alter column <Name> type <Type>
|
||||||
drop_table: "drop table <Name>"
|
drop_table: "drop table <Name>"
|
||||||
drop_column: "drop column [from] [table] <Table>: <Name>"
|
drop_column: "drop column [from] [table] <Table>: <Name>"
|
||||||
drop_relationship: |-
|
drop_relationship: |-
|
||||||
@@ -871,8 +873,10 @@ ok:
|
|||||||
client_side:
|
client_side:
|
||||||
# Per-cell transformation notice when `change column ...` rewrote
|
# Per-cell transformation notice when `change column ...` rewrote
|
||||||
# at least one stored value (storage-class change or non-identity
|
# at least one stored value (storage-class change or non-identity
|
||||||
# mapping). `lossy` variant fires under --force-conversion when
|
# mapping). `lossy` variant fires when information was discarded —
|
||||||
# information was discarded.
|
# under simple-mode `--force-conversion`, and under advanced-mode
|
||||||
|
# `alter table … alter column … type …`, which always converts
|
||||||
|
# (ADR-0035 §7).
|
||||||
transformed: |-
|
transformed: |-
|
||||||
[client-side] {count} row(s) were transformed before being stored. In raw SQL this would need an explicit `CAST` or application-level code.
|
[client-side] {count} row(s) were transformed before being stored. In raw SQL this would need an explicit `CAST` or application-level code.
|
||||||
transformed_lossy: |-
|
transformed_lossy: |-
|
||||||
|
|||||||
+11
-1
@@ -33,7 +33,7 @@ use crate::db::{
|
|||||||
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
||||||
QueryPlan, TableDescription, UpdateResult,
|
QueryPlan, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::{AlterTableAction, Command, ColumnSpec};
|
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
|
||||||
use crate::dsl::walker::Severity;
|
use crate::dsl::walker::Severity;
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
use crate::project::{
|
use crate::project::{
|
||||||
@@ -2114,6 +2114,16 @@ async fn execute_command_typed(
|
|||||||
.rename_column(table, old, new, src)
|
.rename_column(table, old, new, src)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||||
|
// `ALTER COLUMN … TYPE` reuses the simple `change column`
|
||||||
|
// executor with `ForceConversion` — the ADR-0035 §7
|
||||||
|
// advanced policy (lossy converts with a note; no force
|
||||||
|
// flag; static-refused / incompatible still refuse). The
|
||||||
|
// ChangeColumn outcome surfaces the client-side lossy note,
|
||||||
|
// shared with simple mode.
|
||||||
|
AlterTableAction::AlterColumnType { column, ty } => database
|
||||||
|
.change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src)
|
||||||
|
.await
|
||||||
|
.map(CommandOutcome::ChangeColumn),
|
||||||
},
|
},
|
||||||
Command::AddConstraint {
|
Command::AddConstraint {
|
||||||
table,
|
table,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! rename-drift bug that would break a later rebuild).
|
//! rename-drift bug that would break a later rebuild).
|
||||||
|
|
||||||
use rdbms_playground::db::Database;
|
use rdbms_playground::db::Database;
|
||||||
use rdbms_playground::dsl::{ColumnSpec, Type};
|
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, Type};
|
||||||
use rdbms_playground::persistence::Persistence;
|
use rdbms_playground::persistence::Persistence;
|
||||||
use rdbms_playground::project;
|
use rdbms_playground::project;
|
||||||
|
|
||||||
@@ -72,10 +72,34 @@ fn simple_column_ops_refuse_internal_tables() {
|
|||||||
"drop column on an internal table is refused"
|
"drop column on an internal table is refused"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
r.block_on(db.rename_column(internal, "table_name".to_string(), "tn".to_string(), None))
|
r.block_on(db.rename_column(
|
||||||
.is_err(),
|
internal.clone(),
|
||||||
|
"table_name".to_string(),
|
||||||
|
"tn".to_string(),
|
||||||
|
None
|
||||||
|
))
|
||||||
|
.is_err(),
|
||||||
"rename column on an internal table is refused"
|
"rename column on an internal table is refused"
|
||||||
);
|
);
|
||||||
|
// `change column` (the simple surface; also the SQL `ALTER COLUMN
|
||||||
|
// TYPE` decomposition target — ADR-0035 §4f) is refused too: the
|
||||||
|
// guard lives in `do_change_column_type`. It refuses up-front as
|
||||||
|
// "no such table" (the sibling-executor contract), not via the
|
||||||
|
// incidental "no user-facing type metadata" path internal tables
|
||||||
|
// happen to hit.
|
||||||
|
let err = r
|
||||||
|
.block_on(db.change_column_type(
|
||||||
|
internal,
|
||||||
|
"table_name".to_string(),
|
||||||
|
Type::Int,
|
||||||
|
ChangeColumnMode::Default,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.expect_err("change column type on an internal table is refused");
|
||||||
|
assert!(
|
||||||
|
format!("{err:?}").contains("NoSuchTable"),
|
||||||
|
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+228
-8
@@ -1,17 +1,21 @@
|
|||||||
//! Sub-phase 4e Tier-3 end-to-end tests for advanced-mode SQL
|
//! Sub-phase 4e/4f Tier-3 end-to-end tests for advanced-mode SQL
|
||||||
//! `ALTER TABLE` add/drop/rename column (ADR-0035 §4e).
|
//! `ALTER TABLE` (ADR-0035 §4e + §4f).
|
||||||
//!
|
//!
|
||||||
//! These drive the **full advanced-mode pipeline** via `run_replay`: a
|
//! These drive the **full advanced-mode pipeline** via `run_replay`: a
|
||||||
//! literal `alter table …` line is parsed in Advanced mode, routed to
|
//! literal `alter table …` line is parsed in Advanced mode, routed to
|
||||||
//! `Command::SqlAlterTable`, decomposed by the runtime to the existing
|
//! `Command::SqlAlterTable`, decomposed by the runtime to the existing
|
||||||
//! column executor, and persisted. They prove the decomposition for all
|
//! column executor, and persisted. 4e proves the decomposition for
|
||||||
//! three actions and the **raw-text DEFAULT/CHECK ADD COLUMN** path (the
|
//! add/drop/rename column and the **raw-text DEFAULT/CHECK ADD COLUMN**
|
||||||
//! 4e executor extension). The drop/rename refusals (PK / FK / index /
|
//! path; 4f adds `ALTER COLUMN <col> TYPE <type>`, decomposed to
|
||||||
//! table-CHECK) live in the shared executors and are covered by
|
//! `change_column_type` with `ChangeColumnMode::ForceConversion` — the
|
||||||
//! `tests/column_op_guards.rs` — the SQL surface reaches the same code.
|
//! §7 advanced policy (lossy converts with a note, no force flag;
|
||||||
|
//! static-refused / incompatible still refuse). The drop/rename refusals
|
||||||
|
//! (PK / FK / index / table-CHECK) and the internal-table guard live in
|
||||||
|
//! the shared executors and are covered by `tests/column_op_guards.rs` —
|
||||||
|
//! the SQL surface reaches the same code.
|
||||||
|
|
||||||
use rdbms_playground::db::Database;
|
use rdbms_playground::db::Database;
|
||||||
use rdbms_playground::dsl::Value;
|
use rdbms_playground::dsl::{Type, Value};
|
||||||
use rdbms_playground::event::AppEvent;
|
use rdbms_playground::event::AppEvent;
|
||||||
use rdbms_playground::persistence::Persistence;
|
use rdbms_playground::persistence::Persistence;
|
||||||
use rdbms_playground::project;
|
use rdbms_playground::project;
|
||||||
@@ -36,6 +40,41 @@ fn open() -> (project::Project, Database, tempfile::TempDir) {
|
|||||||
(project, db, dir)
|
(project, db, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let project =
|
||||||
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||||
|
let db = Database::open_with_persistence_and_undo(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(project.path().to_path_buf()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("db");
|
||||||
|
(project, db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a single-conversion script through the full pipeline and report
|
||||||
|
/// whether it aborted with a `ReplayFailed` (i.e. the command was
|
||||||
|
/// refused). Used to assert the SQL `ALTER COLUMN TYPE` path reaches the
|
||||||
|
/// shared executor's static / incompatible refusals.
|
||||||
|
fn replay_is_refused(script: &str) -> bool {
|
||||||
|
let (project, db, _d) = open();
|
||||||
|
let r = rt();
|
||||||
|
std::fs::write(project.path().join("conv.commands"), script).expect("write script");
|
||||||
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||||
|
matches!(events.last(), Some(AppEvent::ReplayFailed { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current user-facing type of column `name` in table `T`.
|
||||||
|
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
|
||||||
|
r.block_on(db.describe_table("T".to_string(), None))
|
||||||
|
.expect("describe")
|
||||||
|
.columns
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.name == name)
|
||||||
|
.and_then(|c| c.user_type)
|
||||||
|
}
|
||||||
|
|
||||||
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||||
r.block_on(db.describe_table("T".to_string(), None))
|
r.block_on(db.describe_table("T".to_string(), None))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
@@ -138,3 +177,184 @@ fn e2e_alter_add_column_survives_rebuild() {
|
|||||||
"the ALTER-added CHECK is intact after rebuild"
|
"the ALTER-added CHECK is intact after rebuild"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 4f: ALTER COLUMN … TYPE (ADR-0035 §4f) -----------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_clean_and_lossy_convert() {
|
||||||
|
// The key 4f assertion: the SQL ALTER COLUMN TYPE path wires
|
||||||
|
// `ForceConversion`. A lossy `real → int` (3.7 → 3) is therefore
|
||||||
|
// *performed*, not refused — under `Default` mode the replay line
|
||||||
|
// would refuse and abort (count < 6). A clean `int → text` stringifies.
|
||||||
|
let (project, db, _d) = open();
|
||||||
|
let r = rt();
|
||||||
|
std::fs::write(
|
||||||
|
project.path().join("conv.commands"),
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (real)\n\
|
||||||
|
add column T: w (int)\n\
|
||||||
|
insert into T (id, v, w) values (1, 3.7, 42)\n\
|
||||||
|
alter table T alter column v type int\n\
|
||||||
|
alter table T alter column w type text\n",
|
||||||
|
)
|
||||||
|
.expect("write script");
|
||||||
|
|
||||||
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||||
|
match events.last().expect("at least one event") {
|
||||||
|
AppEvent::ReplayCompleted { count, .. } => {
|
||||||
|
assert_eq!(*count, 6, "all six lines replayed; events: {events:?}");
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = r
|
||||||
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||||
|
.expect("query")
|
||||||
|
.rows;
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
// v (col 1): lossy real→int performed → 3.7 stored as 3.
|
||||||
|
assert_eq!(rows[0][1].as_deref(), Some("3"), "lossy real→int performed (3.7→3)");
|
||||||
|
// w (col 2): clean int→text stringified → "42".
|
||||||
|
assert_eq!(rows[0][2].as_deref(), Some("42"), "clean int→text stringified");
|
||||||
|
|
||||||
|
// The columns now carry the new user-facing types (round-tripped
|
||||||
|
// through the metadata).
|
||||||
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int));
|
||||||
|
assert_eq!(col_type(&db, &r, "w"), Some(Type::Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_int_to_serial_is_allowed() {
|
||||||
|
// ADR-0035 §7's "static-refused (→serial …)" summary is looser than
|
||||||
|
// the code: `int → serial` IS allowed (ADR-0018 §8 — auto-fills nulls,
|
||||||
|
// adds UNIQUE on a non-PK column). The SQL path reaches that supported
|
||||||
|
// conversion; the pre-existing non-null value is preserved.
|
||||||
|
let (project, db, _d) = open();
|
||||||
|
let r = rt();
|
||||||
|
std::fs::write(
|
||||||
|
project.path().join("conv.commands"),
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: n (int)\n\
|
||||||
|
insert into T (id, n) values (1, 100)\n\
|
||||||
|
alter table T alter column n type serial\n",
|
||||||
|
)
|
||||||
|
.expect("write script");
|
||||||
|
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||||
|
match events.last().expect("at least one event") {
|
||||||
|
AppEvent::ReplayCompleted { count, .. } => {
|
||||||
|
assert_eq!(*count, 4, "all four lines replayed; events: {events:?}");
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
|
||||||
|
}
|
||||||
|
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
|
||||||
|
let rows = r
|
||||||
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||||
|
.expect("query")
|
||||||
|
.rows;
|
||||||
|
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_incompatible_is_refused() {
|
||||||
|
// text "abc" → int has no valid per-cell conversion → refused (no
|
||||||
|
// force flag overrides incompatibles). The SQL path reaches the
|
||||||
|
// shared executor's incompatible refusal.
|
||||||
|
assert!(
|
||||||
|
replay_is_refused(
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (text)\n\
|
||||||
|
insert into T (id, v) values (1, 'abc')\n\
|
||||||
|
alter table T alter column v type int\n",
|
||||||
|
),
|
||||||
|
"an incompatible text→int conversion is refused via the SQL path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_static_refusals() {
|
||||||
|
// Static refusals are shared by both modes (ADR-0017 §3); the SQL
|
||||||
|
// ALTER COLUMN TYPE path reaches them.
|
||||||
|
assert!(
|
||||||
|
replay_is_refused(
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (text)\n\
|
||||||
|
alter table T alter column v type serial\n",
|
||||||
|
),
|
||||||
|
"text→serial is refused (only int→serial is allowed)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
replay_is_refused(
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (text)\n\
|
||||||
|
alter table T alter column v type blob\n",
|
||||||
|
),
|
||||||
|
"↔ blob is statically refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_on_fk_column_is_refused() {
|
||||||
|
// The column is the child side of a relationship (outbound FK);
|
||||||
|
// changing its type is refused for v1 (ADR-0017 §4.2). The SQL ALTER
|
||||||
|
// COLUMN TYPE path reaches the same executor precondition.
|
||||||
|
assert!(
|
||||||
|
replay_is_refused(
|
||||||
|
"create table P with pk id(int)\n\
|
||||||
|
create table C with pk cid(int)\n\
|
||||||
|
add column C: pid (int)\n\
|
||||||
|
add 1:n relationship from P.id to C.pid\n\
|
||||||
|
alter table C alter column pid type text\n",
|
||||||
|
),
|
||||||
|
"changing the type of a child-side FK column is refused via the SQL path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_survives_rebuild() {
|
||||||
|
// The user_type metadata update is the existing path, so the
|
||||||
|
// converted type round-trips through the text artifacts and survives
|
||||||
|
// a rebuild.
|
||||||
|
let (project, db, _d) = open();
|
||||||
|
let r = rt();
|
||||||
|
std::fs::write(
|
||||||
|
project.path().join("conv.commands"),
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (real)\n\
|
||||||
|
alter table T alter column v type int\n",
|
||||||
|
)
|
||||||
|
.expect("write script");
|
||||||
|
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||||
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "converted before rebuild");
|
||||||
|
|
||||||
|
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||||
|
.expect("rebuild");
|
||||||
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the converted type survives rebuild");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_alter_column_type_is_one_undo_step() {
|
||||||
|
// The runtime decomposes SqlAlterTable::AlterColumnType into ONE
|
||||||
|
// change_column_type call, so the whole conversion is one undo step
|
||||||
|
// (the executor's rebuild is one snapshot) — like the simple
|
||||||
|
// `change column`. Driven through the full SQL pipeline (run_replay
|
||||||
|
// fires the worker snapshot hook per command), then undone in one.
|
||||||
|
let (project, db, _d) = open_with_undo();
|
||||||
|
let r = rt();
|
||||||
|
std::fs::write(
|
||||||
|
project.path().join("conv.commands"),
|
||||||
|
"create table T with pk id(int)\n\
|
||||||
|
add column T: v (real)\n\
|
||||||
|
insert into T (id, v) values (1, 3.7)\n\
|
||||||
|
alter table T alter column v type int\n",
|
||||||
|
)
|
||||||
|
.expect("write script");
|
||||||
|
r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||||
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the SQL ALTER COLUMN TYPE converted v");
|
||||||
|
|
||||||
|
// A single undo reverts the whole conversion.
|
||||||
|
assert!(
|
||||||
|
r.block_on(db.undo()).expect("undo").is_some(),
|
||||||
|
"the conversion was one undo step"
|
||||||
|
);
|
||||||
|
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user