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
+228 -8
View File
@@ -1,17 +1,21 @@
//! Sub-phase 4e Tier-3 end-to-end tests for advanced-mode SQL
//! `ALTER TABLE` add/drop/rename column (ADR-0035 §4e).
//! Sub-phase 4e/4f Tier-3 end-to-end tests for advanced-mode SQL
//! `ALTER TABLE` (ADR-0035 §4e + §4f).
//!
//! These drive the **full advanced-mode pipeline** via `run_replay`: a
//! literal `alter table …` line is parsed in Advanced mode, routed to
//! `Command::SqlAlterTable`, decomposed by the runtime to the existing
//! column executor, and persisted. They prove the decomposition for all
//! three actions and the **raw-text DEFAULT/CHECK ADD COLUMN** path (the
//! 4e executor extension). The drop/rename refusals (PK / FK / index /
//! table-CHECK) live in the shared executors and are covered by
//! `tests/column_op_guards.rs` — the SQL surface reaches the same code.
//! column executor, and persisted. 4e proves the decomposition for
//! add/drop/rename column and the **raw-text DEFAULT/CHECK ADD COLUMN**
//! path; 4f adds `ALTER COLUMN <col> TYPE <type>`, decomposed to
//! `change_column_type` with `ChangeColumnMode::ForceConversion` — the
//! §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::dsl::Value;
use rdbms_playground::dsl::{Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
@@ -36,6 +40,41 @@ fn open() -> (project::Project, Database, tempfile::TempDir) {
(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> {
r.block_on(db.describe_table("T".to_string(), None))
.expect("describe")
@@ -138,3 +177,184 @@ fn e2e_alter_add_column_survives_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");
}