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:
@@ -10,7 +10,7 @@
|
||||
//! rename-drift bug that would break a later rebuild).
|
||||
|
||||
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::project;
|
||||
|
||||
@@ -72,10 +72,34 @@ fn simple_column_ops_refuse_internal_tables() {
|
||||
"drop column on an internal table is refused"
|
||||
);
|
||||
assert!(
|
||||
r.block_on(db.rename_column(internal, "table_name".to_string(), "tn".to_string(), None))
|
||||
.is_err(),
|
||||
r.block_on(db.rename_column(
|
||||
internal.clone(),
|
||||
"table_name".to_string(),
|
||||
"tn".to_string(),
|
||||
None
|
||||
))
|
||||
.is_err(),
|
||||
"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]
|
||||
|
||||
+228
-8
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user