diff --git a/docs/adr/0035-advanced-mode-sql-ddl.md b/docs/adr/0035-advanced-mode-sql-ddl.md index afdbee1..4dcea1f 100644 --- a/docs/adr/0035-advanced-mode-sql-ddl.md +++ b/docs/adr/0035-advanced-mode-sql-ddl.md @@ -662,13 +662,26 @@ and the *canonical/echoed* form change. The echo (ADR-0038) emits Four new `AlterColumnType`-family actions under `ALTER COLUMN `: -| Spelling | Standing | Decomposes to (ADR-0029 executor) | +| Spelling | Standing | Decomposes to | |---|---|---| -| `SET DEFAULT ` | **ISO-standard** | `do_add_constraint(Default)` | +| `SET DEFAULT ` | **ISO-standard** | `do_set_column_default` (raw-SQL — see note) | | `DROP DEFAULT` | **ISO-standard** | `do_drop_constraint(Default)` | | `SET NOT NULL` | **documented extension** | `do_add_constraint(NotNull)` | | `DROP NOT NULL` | **documented extension** | `do_drop_constraint(NotNull)` | +**Implementation correction (2026-05-27, during build).** The draft said +`SET DEFAULT` decomposes to `do_add_constraint(Default)`. It cannot: +`do_add_constraint`'s `Constraint::Default(Value)` carries a *typed* +`Value` (the simple-mode shape), but advanced `SET DEFAULT ` is +**raw `sql_expr` text** with no AST (it may be `(1 + 1)`, a function +call, …). So `SET DEFAULT` decomposes to a small dedicated executor +**`do_set_column_default(table, column, default_sql)`** that sets the +column's raw `default_sql` and rebuilds — mirroring `do_add_constraint`'s +`Default` branch (the §6 `serial`/`shortid` refusal, no dry-run — a +default never touches existing rows) but accepting raw SQL instead of a +`Value`. `SET NOT NULL` / `DROP NOT NULL` / `DROP DEFAULT` reuse the +ADR-0029 `do_add_constraint` / `do_drop_constraint` executors unchanged. + `SET DEFAULT`/`DROP DEFAULT` are taken directly from the ISO `` set. **`NOT NULL` toggling has no ISO spelling** — in the standard `NOT NULL` is a column constraint, not an in-place diff --git a/src/app.rs b/src/app.rs index 80da85f..5ea7723 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1614,6 +1614,21 @@ impl App { Some(table.as_str()), Some(column.as_str()), ), + // ADR-0035 Amendment 2: the column-attribute set/drop forms + // decompose to add/drop constraint, and name a column (so + // the friendly error can pinpoint it). + AlterTableAction::SetColumnNotNull { column } + | AlterTableAction::SetColumnDefault { column, .. } => ( + Operation::AddConstraint, + Some(table.as_str()), + Some(column.as_str()), + ), + AlterTableAction::DropColumnNotNull { column } + | AlterTableAction::DropColumnDefault { column } => ( + Operation::DropConstraint, + Some(table.as_str()), + Some(column.as_str()), + ), AlterTableAction::AddTableConstraint { .. } => { (Operation::AddConstraint, Some(table.as_str()), None) } diff --git a/src/db.rs b/src/db.rs index 40db4ea..ed29d2a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -608,6 +608,17 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + /// `ALTER TABLE … ALTER COLUMN SET DEFAULT ` — set a + /// column's default to raw SQL text (ADR-0035 Amendment 2). Distinct + /// from `AddConstraint(Default(Value))` because the advanced default + /// is raw `sql_expr` text with no typed `Value`. + SetColumnDefault { + table: String, + column: String, + default_sql: String, + source: Option, + reply: oneshot::Sender>, + }, /// `ALTER TABLE … ADD [CONSTRAINT ] CHECK ()` — a /// table-level CHECK, named or unnamed (ADR-0035 §4g). AlterAddTableCheck { @@ -1131,6 +1142,27 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// `ALTER TABLE … ALTER COLUMN SET DEFAULT ` — set the + /// column's default to raw SQL text (ADR-0035 Amendment 2). + pub async fn set_column_default( + &self, + table: String, + column: String, + default_sql: String, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::SetColumnDefault { + table, + column, + default_sql, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + /// `ALTER TABLE … ADD [CONSTRAINT ] CHECK ()` — a /// table-level CHECK (ADR-0035 §4g). pub async fn alter_add_table_check( @@ -2366,6 +2398,24 @@ fn handle_request( kind, )); } + Request::SetColumnDefault { + table, + column, + default_sql, + source, + reply, + } => { + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_set_column_default( + conn, + persistence, + source.as_deref(), + &table, + &column, + &default_sql, + ) + }); + } Request::AlterAddTableCheck { table, name, @@ -4034,6 +4084,72 @@ fn do_drop_constraint( do_describe_table(conn, table) } +/// `ALTER TABLE … ALTER COLUMN SET DEFAULT ` — set the +/// column's default to raw SQL text (ADR-0035 Amendment 2). Mirrors +/// `do_add_constraint`'s `Default` branch (the §6 `serial`/`shortid` +/// refusal; no dry-run — a default never touches existing rows) but +/// takes raw `sql_expr` text instead of a typed `Value` (advanced +/// `SET DEFAULT` has no AST). DEFAULT is recoverable from the engine +/// catalog, so there is no metadata write — the rebuilt table's DDL +/// carries it (ADR-0029 §7). +fn do_set_column_default( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + table: &str, + column: &str, + default_sql: &str, +) -> Result { + let canonical_table = require_canonical_table(conn, table)?; + let table = canonical_table.as_str(); + let old_schema = read_schema(conn, table)?; + let col_user_type = { + let col = old_schema + .columns + .iter() + .find(|c| c.name == column) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {table}.{column}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + col.user_type + }; + + // ADR-0029 §6 — an auto-generated column fills its own values, so a + // `default` would be a second, ambiguous source of "the value when + // none is given" (mirrors do_add_constraint). + if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) { + return Err(DbError::Unsupported(format!( + "`{table}.{column}` is a {ty} column — it auto-fills its own \ + values, so it cannot also carry a `default`.", + ty = col_user_type.expect("matched Some above").keyword(), + ))); + } + + let mut new_schema = old_schema.clone(); + { + let target = new_schema + .columns + .iter_mut() + .find(|c| c.name == column) + .expect("column existence checked above"); + target.default_sql = Some(default_sql.to_string()); + } + + let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { + let changes = Changes { + schema_dirty: true, + rewritten_tables: vec![table.to_string()], + ..Changes::default() + }; + finalize_persistence(tx, persistence, source, &changes)?; + Ok(()) + }; + + rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; + do_describe_table(conn, table) +} + /// A row's primary-key cell values paired with its value in /// the column under test — the unit an ADR-0029 §5 dry-run /// scans. diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 7f350d6..7448261 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -756,8 +756,26 @@ pub enum AlterTableAction { /// 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. + /// step (the executor's rebuild). ADR-0035 §4f. The ISO synonym + /// `SET DATA TYPE` (canonical) and the PostgreSQL `TYPE` shorthand + /// both build this action (ADR-0035 Amendment 2). AlterColumnType { column: String, ty: Type }, + /// `ALTER COLUMN SET NOT NULL` (ADR-0035 Amendment 2) — a + /// documented PostgreSQL extension (ISO has no in-place NOT-NULL + /// verb). Decomposes to `do_add_constraint(NotNull)` (ADR-0029). + SetColumnNotNull { column: String }, + /// `ALTER COLUMN DROP NOT NULL` (ADR-0035 Amendment 2). + /// Decomposes to `do_drop_constraint(NotNull)`. + DropColumnNotNull { column: String }, + /// `ALTER COLUMN SET DEFAULT ` (ADR-0035 Amendment 2, + /// ISO). `default_sql` is the raw `sql_expr` text (the §4a.2 / §4e + /// mechanism — `sql_expr` builds no AST, so the default cannot be a + /// typed `Value`). Decomposes to the dedicated `do_set_column_default` + /// executor (not `do_add_constraint`, which is `Value`-based). + SetColumnDefault { column: String, default_sql: String }, + /// `ALTER COLUMN DROP DEFAULT` (ADR-0035 Amendment 2, ISO). + /// Decomposes to `do_drop_constraint(Default)`. + DropColumnDefault { column: String }, /// `ADD [CONSTRAINT ] (CHECK (…) | UNIQUE (…) | FOREIGN KEY /// (…) REFERENCES …)` — a table-level constraint (ADR-0035 §4g). The /// `name` is the `CONSTRAINT ` prefix (the FK carries its own diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 33c60ff..215fbf6 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1948,16 +1948,55 @@ const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES); static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL]; const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES); -// `ALTER COLUMN 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). +// `ALTER COLUMN ` (ADR-0035 §4f + Amendment 2). The action +// tail is a Choice on distinct concrete keywords — `type` / `set` / +// `drop` — trap-safe. The type slot reuses SQL_TYPE (the same alias map + +// `double precision` pair the CREATE TABLE / ADD COLUMN forms use). +// +// TYPE — PostgreSQL shorthand (§4f) +// SET DATA TYPE — ISO canonical synonym (Amendment 2) +// SET NOT NULL — documented extension (Amendment 2) +// SET DEFAULT — ISO (Amendment 2), raw sql_expr text +// DROP NOT NULL — ISO-ish (Amendment 2) +// DROP DEFAULT — ISO (Amendment 2) +// +// `NOT NULL` reused by both SET and DROP tails (distinct sibling leads in +// each). `DEFAULT ` reuses the CREATE TABLE `DEFAULT_NODES` so a +// default is one syntax, captured as raw text (sql_expr builds no AST). +static AT_AC_TYPE_NODES: &[Node] = &[ + Node::Word(Word::keyword("type")), + super::sql_create_table::SQL_TYPE, +]; +const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES); +static AT_AC_NOT_NULL_NODES: &[Node] = + &[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))]; +const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES); +static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[ + Node::Word(Word::keyword("data")), + Node::Word(Word::keyword("type")), + super::sql_create_table::SQL_TYPE, +]; +const AT_AC_SET_DATA_TYPE: Node = Node::Seq(AT_AC_SET_DATA_TYPE_NODES); +static AT_AC_SET_TAIL_CHOICES: &[Node] = &[ + AT_AC_SET_DATA_TYPE, + AT_AC_NOT_NULL, + Node::Seq(super::sql_create_table::DEFAULT_NODES), +]; +const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES); +static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL]; +const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES); +static AT_AC_DROP_TAIL_CHOICES: &[Node] = + &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))]; +const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES); +static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL]; +const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES); +static AT_ALTER_COLUMN_TAIL_CHOICES: &[Node] = &[AT_AC_TYPE, AT_AC_SET, AT_AC_DROP]; +const AT_ALTER_COLUMN_TAIL: Node = Node::Choice(AT_ALTER_COLUMN_TAIL_CHOICES); 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, + AT_ALTER_COLUMN_TAIL, ]; const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES); @@ -2148,6 +2187,42 @@ fn build_alter_column_type(path: &MatchedPath) -> Result SET/DROP NOT NULL` / `SET/DROP DEFAULT` +/// action (ADR-0035 Amendment 2). `SET DATA TYPE` is handled by the +/// `type`-keyword branch, so this only sees the four constraint forms. +/// The 2×2 is decided by `set` (vs drop) and `default` (vs not null); +/// `SET DEFAULT`'s value is captured as raw `sql_expr` text by span, +/// reusing `build_alter_add_column_spec`'s mechanism (no AST — 4a.2). +fn build_alter_column_attr( + path: &MatchedPath, + source: &str, +) -> Result { + let column = require_ident(path, "column_name")?; + let is_set = path.contains_word("set"); + let is_default = path.contains_word("default"); + Ok(match (is_set, is_default) { + (true, true) => { + let mut items = path.items.iter().peekable(); + let mut default_sql: Option = None; + while let Some(item) = items.next() { + if matches!(&item.kind, MatchedKind::Word("default")) + && let Some((start, end)) = capture_expr_span(&mut items) + { + default_sql = Some(source[start..end].trim().to_string()); + } + } + let default_sql = default_sql.ok_or_else(|| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "set default needs a value".to_string())], + })?; + AlterTableAction::SetColumnDefault { column, default_sql } + } + (false, true) => AlterTableAction::DropColumnDefault { column }, + (true, false) => AlterTableAction::SetColumnNotNull { column }, + (false, false) => AlterTableAction::DropColumnNotNull { column }, + }) +} + /// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one /// action `Choice` branch matched; the builder recovers which from the /// matched words. Discrimination order matters: @@ -2171,7 +2246,19 @@ fn build_alter_column_type(path: &MatchedPath) -> Result Result { let table = require_ident(path, "table_name")?; let action = if path.contains_word("type") { + // covers `TYPE ` and the ISO synonym `SET DATA TYPE ` build_alter_column_type(path)? + } else if path.contains_word("column") + && !path.contains_word("add") + && !path.contains_word("rename") + && (path.contains_word("set") + || path.contains_word("default") + || (path.contains_word("not") && path.contains_word("null"))) + { + // ADR-0035 Amendment 2: `ALTER COLUMN SET/DROP NOT NULL` and + // `SET/DROP DEFAULT`. `add column … not null/default` is excluded + // by `!add`; plain `drop column` lacks the attribute markers. + build_alter_column_attr(path, source)? } else if path.contains_word("column") { if path.contains_word("add") { AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?)) @@ -2934,6 +3021,92 @@ mod sql_alter_table_tests { )); } + // --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill --- + + #[test] + fn alter_column_set_data_type_is_a_type_synonym() { + // ISO `SET DATA TYPE` is the canonical form; it yields the same + // AlterColumnType action as the PostgreSQL `TYPE` shorthand. + match alter("alter table T alter column qty set data type int").1 { + AlterTableAction::AlterColumnType { column, ty } => { + assert_eq!(column, "qty"); + assert_eq!(ty, crate::dsl::types::Type::Int); + } + other => panic!("expected AlterColumnType, got {other:?}"), + } + // alias map still applies through the synonym + assert!(matches!( + alter("alter table T alter column n set data type double precision").1, + AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. } + )); + } + + #[test] + fn alter_column_set_not_null_parses() { + let (table, action) = alter("alter table T alter column email set not null"); + assert_eq!(table, "T"); + match action { + AlterTableAction::SetColumnNotNull { column } => assert_eq!(column, "email"), + other => panic!("expected SetColumnNotNull, got {other:?}"), + } + } + + #[test] + fn alter_column_drop_not_null_parses() { + match alter("alter table T alter column email drop not null").1 { + AlterTableAction::DropColumnNotNull { column } => assert_eq!(column, "email"), + other => panic!("expected DropColumnNotNull, got {other:?}"), + } + } + + #[test] + fn alter_column_set_default_captures_raw_expr() { + match alter("alter table T alter column qty set default 0").1 { + AlterTableAction::SetColumnDefault { column, default_sql } => { + assert_eq!(column, "qty"); + assert_eq!(default_sql, "0"); + } + other => panic!("expected SetColumnDefault, got {other:?}"), + } + // a parenthesised expression default round-trips as raw text + match alter("alter table T alter column qty set default (1 + 1)").1 { + AlterTableAction::SetColumnDefault { default_sql, .. } => { + assert_eq!(default_sql, "(1 + 1)"); + } + other => panic!("expected SetColumnDefault, got {other:?}"), + } + } + + #[test] + fn alter_column_drop_default_parses() { + match alter("alter table T alter column qty drop default").1 { + AlterTableAction::DropColumnDefault { column } => assert_eq!(column, "qty"), + other => panic!("expected DropColumnDefault, got {other:?}"), + } + } + + #[test] + fn alter_column_gap_fill_does_not_steal_the_existing_actions() { + // The new set/drop column-attribute forms must not misroute the + // top-level add/drop/rename-column or the bare `type` form. + assert!(matches!( + alter("alter table T add column note text not null").1, + AlterTableAction::AddColumn(_) + )); + assert!(matches!( + alter("alter table T drop column note").1, + AlterTableAction::DropColumn { .. } + )); + assert!(matches!( + alter("alter table T alter column a type text").1, + AlterTableAction::AlterColumnType { .. } + )); + assert!(matches!( + alter("alter table T drop constraint c_chk").1, + AlterTableAction::DropConstraint { .. } + )); + } + #[test] fn type_discriminator_probe_column_named_type() { // PROBE (DA): the `type`-keyword discriminator keys on the literal diff --git a/src/runtime.rs b/src/runtime.rs index 28c2410..4682070 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -33,7 +33,7 @@ use crate::db::{ Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult, QueryPlan, TableDescription, UpdateResult, }; -use crate::dsl::command::TableConstraint; +use crate::dsl::command::{Constraint, ConstraintKind, TableConstraint}; use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec}; use crate::dsl::walker::Severity; use crate::event::AppEvent; @@ -2185,6 +2185,26 @@ async fn execute_command_typed( .change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src) .await .map(CommandOutcome::ChangeColumn), + // ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill. + // SET/DROP NOT NULL and DROP DEFAULT reuse the ADR-0029 + // executors; SET DEFAULT needs the raw-SQL path (sql_expr has + // no typed Value). + AlterTableAction::SetColumnNotNull { column } => database + .add_constraint(table, column, Constraint::NotNull, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), + AlterTableAction::DropColumnNotNull { column } => database + .drop_constraint(table, column, ConstraintKind::NotNull, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), + AlterTableAction::SetColumnDefault { column, default_sql } => database + .set_column_default(table, column, default_sql, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), + AlterTableAction::DropColumnDefault { column } => database + .drop_constraint(table, column, ConstraintKind::Default, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), // `ADD [CONSTRAINT ] (CHECK | UNIQUE | FOREIGN KEY)` // (ADR-0035 §4g) — each reuses an existing low-level executor // (the FK via the relationship machinery `add 1:n diff --git a/tests/sql_alter_table.rs b/tests/sql_alter_table.rs index b54c864..1d6f360 100644 --- a/tests/sql_alter_table.rs +++ b/tests/sql_alter_table.rs @@ -1279,3 +1279,188 @@ fn e2e_rename_table_refusals() { let tables = table_names(&db, &r); assert!(tables.contains(&"T".to_string()) && tables.contains(&"X".to_string())); } + +// --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill ------------- +// +// Full advanced-mode pipeline (parse → SqlAlterTable → runtime +// decomposition → ADR-0029 executors / the raw-default executor → +// persist), driven via run_replay. Behaviour-based assertions: the +// constraint is exercised by a follow-up insert, the way the 4e/4f tests +// exercise CHECK. + +/// `ALTER COLUMN … SET NOT NULL` on a clean column succeeds and is then +/// enforced (a NULL insert is refused). +#[test] +fn e2e_alter_column_set_not_null_enforced() { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("a.commands"), + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + insert into T (id, qty) values (1, 5)\n\ + alter table T alter column qty set not null\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "a.commands")); + assert!( + matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + "set not null on a clean column succeeds; events: {events:?}" + ); + assert!( + r.block_on(db.insert( + "T".to_string(), + Some(vec!["id".to_string(), "qty".to_string()]), + vec![Value::Number("2".to_string()), Value::Null], + Some("insert".to_string()), + )) + .is_err(), + "SET NOT NULL is enforced — a NULL is refused" + ); +} + +/// The ADR-0029 §5 dry-run fires through the SQL surface: SET NOT NULL on +/// a column that already holds a NULL is refused (the replay aborts). +#[test] +fn e2e_alter_column_set_not_null_refused_on_existing_null() { + assert!(replay_is_refused( + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + insert into T (id) values (1)\n\ + alter table T alter column qty set not null\n", + )); +} + +/// `DROP NOT NULL` reverses it — a NULL insert is accepted again. +#[test] +fn e2e_alter_column_drop_not_null_allows_nulls() { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("a.commands"), + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + alter table T alter column qty set not null\n\ + alter table T alter column qty drop not null\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "a.commands")); + assert!( + matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + "events: {events:?}" + ); + r.block_on(db.insert( + "T".to_string(), + Some(vec!["id".to_string(), "qty".to_string()]), + vec![Value::Number("1".to_string()), Value::Null], + Some("insert".to_string()), + )) + .expect("NULL qty accepted after DROP NOT NULL"); +} + +/// `ALTER COLUMN … SET DEFAULT ` backfills an omitted insert. +#[test] +fn e2e_alter_column_set_default_applies() { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("a.commands"), + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + alter table T alter column qty set default 5\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "a.commands")); + assert!( + matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })), + "events: {events:?}" + ); + r.block_on(db.insert( + "T".to_string(), + Some(vec!["id".to_string()]), + vec![Value::Number("1".to_string())], + Some("insert".to_string()), + )) + .expect("insert omitting qty"); + 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("5"), + "SET DEFAULT 5 backfilled the omitted column" + ); +} + +/// `SET DEFAULT` on an auto-generated column is refused (ADR-0029 §6). +#[test] +fn e2e_alter_column_set_default_refused_on_serial() { + // `create table T with pk` → id serial; a default on it is refused. + assert!(replay_is_refused( + "create table T with pk\n\ + alter table T alter column id set default 0\n", + )); +} + +/// `DROP DEFAULT` removes it — an omitted insert is then NULL. +#[test] +fn e2e_alter_column_drop_default_removes_it() { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("a.commands"), + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + alter table T alter column qty set default 5\n\ + alter table T alter column qty drop default\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "a.commands")); + assert!( + matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + "events: {events:?}" + ); + r.block_on(db.insert( + "T".to_string(), + Some(vec!["id".to_string()]), + vec![Value::Number("1".to_string())], + Some("insert".to_string()), + )) + .expect("insert omitting qty"); + let rows = r + .block_on(db.query_data("T".to_string(), None, None, None)) + .expect("query") + .rows; + assert_eq!( + rows[0][1].as_deref(), + None, + "DROP DEFAULT — the omitted column is NULL" + ); +} + +/// The ISO `SET DATA TYPE` synonym executes the same conversion as the +/// PostgreSQL `TYPE` shorthand (ADR-0035 Amendment 2). +#[test] +fn e2e_alter_column_set_data_type_converts() { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("a.commands"), + "create table T with pk id(int)\n\ + add column T: qty (int)\n\ + insert into T (id, qty) values (1, 7)\n\ + alter table T alter column qty set data type text\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "a.commands")); + assert!( + matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + "events: {events:?}" + ); + assert_eq!( + col_type(&db, &r, "qty"), + Some(Type::Text), + "SET DATA TYPE converted qty to text (same as the TYPE shorthand)" + ); +}