feat: advanced ALTER COLUMN SET/DROP NOT NULL & DEFAULT, SET DATA TYPE (ADR-0035 Am2)
The standard-first ALTER COLUMN constraint gap-fill advanced mode lacked: - ALTER COLUMN <c> SET DATA TYPE <ty> — ISO canonical synonym for the PostgreSQL TYPE shorthand (same AlterColumnType action + executor). - SET NOT NULL / DROP NOT NULL — reuse the ADR-0029 do_add_constraint / do_drop_constraint executors (dry-run + internal-table guards free). - SET DEFAULT <expr> / DROP DEFAULT — SET DEFAULT uses a dedicated raw-SQL executor (do_set_column_default); sql_expr yields no typed Value, so it can't go through do_add_constraint. DROP DEFAULT reuses do_drop_constraint. Grammar: AT_ALTER_COLUMN gains a tail Choice (type / set / drop), reusing SQL_TYPE and the CREATE TABLE DEFAULT_NODES; builder dispatch routes the new column-attribute forms; runtime decomposes to the executors. ADR-0035 Am2 corrected in-place: SET DEFAULT decomposes to do_set_column_default, not do_add_constraint (Value-based) — found during build. Tests (test-first): 6 parse + 7 Tier-3 execution via run_replay. Suite 1962/0/1; clippy clean.
This commit is contained in:
@@ -662,13 +662,26 @@ and the *canonical/echoed* form change. The echo (ADR-0038) emits
|
|||||||
|
|
||||||
Four new `AlterColumnType`-family actions under `ALTER COLUMN <c>`:
|
Four new `AlterColumnType`-family actions under `ALTER COLUMN <c>`:
|
||||||
|
|
||||||
| Spelling | Standing | Decomposes to (ADR-0029 executor) |
|
| Spelling | Standing | Decomposes to |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SET DEFAULT <expr>` | **ISO-standard** | `do_add_constraint(Default)` |
|
| `SET DEFAULT <expr>` | **ISO-standard** | `do_set_column_default` (raw-SQL — see note) |
|
||||||
| `DROP DEFAULT` | **ISO-standard** | `do_drop_constraint(Default)` |
|
| `DROP DEFAULT` | **ISO-standard** | `do_drop_constraint(Default)` |
|
||||||
| `SET NOT NULL` | **documented extension** | `do_add_constraint(NotNull)` |
|
| `SET NOT NULL` | **documented extension** | `do_add_constraint(NotNull)` |
|
||||||
| `DROP NOT NULL` | **documented extension** | `do_drop_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 <expr>` 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 DEFAULT`/`DROP DEFAULT` are taken directly from the ISO
|
||||||
`<alter column action>` set. **`NOT NULL` toggling has no ISO spelling**
|
`<alter column action>` set. **`NOT NULL` toggling has no ISO spelling**
|
||||||
— in the standard `NOT NULL` is a column constraint, not an in-place
|
— in the standard `NOT NULL` is a column constraint, not an in-place
|
||||||
|
|||||||
+15
@@ -1614,6 +1614,21 @@ impl App {
|
|||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
Some(column.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 { .. } => {
|
AlterTableAction::AddTableConstraint { .. } => {
|
||||||
(Operation::AddConstraint, Some(table.as_str()), None)
|
(Operation::AddConstraint, Some(table.as_str()), None)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,6 +608,17 @@ enum Request {
|
|||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||||
},
|
},
|
||||||
|
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — 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<String>,
|
||||||
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||||
|
},
|
||||||
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
|
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
|
||||||
/// table-level CHECK, named or unnamed (ADR-0035 §4g).
|
/// table-level CHECK, named or unnamed (ADR-0035 §4g).
|
||||||
AlterAddTableCheck {
|
AlterAddTableCheck {
|
||||||
@@ -1131,6 +1142,27 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — 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<String>,
|
||||||
|
) -> Result<TableDescription, DbError> {
|
||||||
|
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 <name>] CHECK (<expr>)` — a
|
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
|
||||||
/// table-level CHECK (ADR-0035 §4g).
|
/// table-level CHECK (ADR-0035 §4g).
|
||||||
pub async fn alter_add_table_check(
|
pub async fn alter_add_table_check(
|
||||||
@@ -2366,6 +2398,24 @@ fn handle_request(
|
|||||||
kind,
|
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 {
|
Request::AlterAddTableCheck {
|
||||||
table,
|
table,
|
||||||
name,
|
name,
|
||||||
@@ -4034,6 +4084,72 @@ fn do_drop_constraint(
|
|||||||
do_describe_table(conn, table)
|
do_describe_table(conn, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — 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<TableDescription, DbError> {
|
||||||
|
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
|
/// A row's primary-key cell values paired with its value in
|
||||||
/// the column under test — the unit an ADR-0029 §5 dry-run
|
/// the column under test — the unit an ADR-0029 §5 dry-run
|
||||||
/// scans.
|
/// scans.
|
||||||
|
|||||||
+19
-1
@@ -756,8 +756,26 @@ pub enum AlterTableAction {
|
|||||||
/// with `ChangeColumnMode::ForceConversion`, which is the ADR-0035 §7
|
/// with `ChangeColumnMode::ForceConversion`, which is the ADR-0035 §7
|
||||||
/// advanced-mode policy (lossy cells are *performed* with a note, no
|
/// advanced-mode policy (lossy cells are *performed* with a note, no
|
||||||
/// force flag; static-refused / incompatible still refuse). One undo
|
/// 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 },
|
AlterColumnType { column: String, ty: Type },
|
||||||
|
/// `ALTER COLUMN <name> 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 <name> DROP NOT NULL` (ADR-0035 Amendment 2).
|
||||||
|
/// Decomposes to `do_drop_constraint(NotNull)`.
|
||||||
|
DropColumnNotNull { column: String },
|
||||||
|
/// `ALTER COLUMN <name> SET DEFAULT <expr>` (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 <name> DROP DEFAULT` (ADR-0035 Amendment 2, ISO).
|
||||||
|
/// Decomposes to `do_drop_constraint(Default)`.
|
||||||
|
DropColumnDefault { column: String },
|
||||||
/// `ADD [CONSTRAINT <name>] (CHECK (…) | UNIQUE (…) | FOREIGN KEY
|
/// `ADD [CONSTRAINT <name>] (CHECK (…) | UNIQUE (…) | FOREIGN KEY
|
||||||
/// (…) REFERENCES …)` — a table-level constraint (ADR-0035 §4g). The
|
/// (…) REFERENCES …)` — a table-level constraint (ADR-0035 §4g). The
|
||||||
/// `name` is the `CONSTRAINT <name>` prefix (the FK carries its own
|
/// `name` is the `CONSTRAINT <name>` prefix (the FK carries its own
|
||||||
|
|||||||
+179
-6
@@ -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];
|
static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL];
|
||||||
const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES);
|
const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES);
|
||||||
|
|
||||||
// `ALTER COLUMN <col> TYPE <type>` (ADR-0035 §4f). The type slot reuses
|
// `ALTER COLUMN <col> <action>` (ADR-0035 §4f + Amendment 2). The action
|
||||||
// SQL_TYPE (the same alias map + `double precision` pair the CREATE
|
// tail is a Choice on distinct concrete keywords — `type` / `set` /
|
||||||
// TABLE / ADD COLUMN forms use). The builder keys on the `type` keyword
|
// `drop` — trap-safe. The type slot reuses SQL_TYPE (the same alias map +
|
||||||
// — unique to this action (ADD COLUMN's type is a `col_type` ident).
|
// `double precision` pair the CREATE TABLE / ADD COLUMN forms use).
|
||||||
|
//
|
||||||
|
// TYPE <ty> — PostgreSQL shorthand (§4f)
|
||||||
|
// SET DATA TYPE <ty> — ISO canonical synonym (Amendment 2)
|
||||||
|
// SET NOT NULL — documented extension (Amendment 2)
|
||||||
|
// SET DEFAULT <expr> — 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 <expr>` 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] = &[
|
static AT_ALTER_COLUMN_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("alter")),
|
Node::Word(Word::keyword("alter")),
|
||||||
Node::Word(Word::keyword("column")),
|
Node::Word(Word::keyword("column")),
|
||||||
COLUMN_NAME,
|
COLUMN_NAME,
|
||||||
Node::Word(Word::keyword("type")),
|
AT_ALTER_COLUMN_TAIL,
|
||||||
super::sql_create_table::SQL_TYPE,
|
|
||||||
];
|
];
|
||||||
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
|
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
|
||||||
|
|
||||||
@@ -2148,6 +2187,42 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
|
|||||||
Ok(AlterTableAction::AlterColumnType { column, ty })
|
Ok(AlterTableAction::AlterColumnType { column, ty })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an `ALTER COLUMN <c> 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<AlterTableAction, ValidationError> {
|
||||||
|
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<String> = 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
|
/// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one
|
||||||
/// action `Choice` branch matched; the builder recovers which from the
|
/// action `Choice` branch matched; the builder recovers which from the
|
||||||
/// matched words. Discrimination order matters:
|
/// matched words. Discrimination order matters:
|
||||||
@@ -2171,7 +2246,19 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
|
|||||||
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("type") {
|
let action = if path.contains_word("type") {
|
||||||
|
// covers `TYPE <ty>` and the ISO synonym `SET DATA TYPE <ty>`
|
||||||
build_alter_column_type(path)?
|
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 <c> 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") {
|
} else if path.contains_word("column") {
|
||||||
if path.contains_word("add") {
|
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)?))
|
||||||
@@ -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]
|
#[test]
|
||||||
fn type_discriminator_probe_column_named_type() {
|
fn type_discriminator_probe_column_named_type() {
|
||||||
// PROBE (DA): the `type`-keyword discriminator keys on the literal
|
// PROBE (DA): the `type`-keyword discriminator keys on the literal
|
||||||
|
|||||||
+21
-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::command::TableConstraint;
|
use crate::dsl::command::{Constraint, ConstraintKind, TableConstraint};
|
||||||
use crate::dsl::{AlterTableAction, ChangeColumnMode, 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;
|
||||||
@@ -2185,6 +2185,26 @@ async fn execute_command_typed(
|
|||||||
.change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src)
|
.change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::ChangeColumn),
|
.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 <name>] (CHECK | UNIQUE | FOREIGN KEY)`
|
// `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)`
|
||||||
// (ADR-0035 §4g) — each reuses an existing low-level executor
|
// (ADR-0035 §4g) — each reuses an existing low-level executor
|
||||||
// (the FK via the relationship machinery `add 1:n
|
// (the FK via the relationship machinery `add 1:n
|
||||||
|
|||||||
@@ -1279,3 +1279,188 @@ fn e2e_rename_table_refusals() {
|
|||||||
let tables = table_names(&db, &r);
|
let tables = table_names(&db, &r);
|
||||||
assert!(tables.contains(&"T".to_string()) && tables.contains(&"X".to_string()));
|
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 <expr>` 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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user