feat: ADR-0035 4e — ALTER TABLE add/drop/rename column
Advanced-only `alter` entry word; ALTER TABLE <T> ADD COLUMN <col> <type> [constraints] | DROP COLUMN <col> | RENAME COLUMN <old> TO <new> -> SqlAlterTable, runtime-decomposed to the existing column executors (do_add_column / do_drop_column / do_rename_column) — one undo step each, no new worker layer. The COLUMN keyword is required (reserves bare RENAME TO for 4h, ADD CONSTRAINT for 4g). - ADD COLUMN takes NOT NULL / UNIQUE / DEFAULT / CHECK (no PK / inline REFERENCES). do_add_column extended to consume the SQL raw-text default_sql / check_sql (sql_expr is validate-only, the 4a.2 mechanism), reaching parity with CREATE TABLE's column constraints. - Drop/rename column refuse a column any CHECK references — table-level AND column-level (incl. a column's own self-check on rename) — the 4a.3 deferral, detected up-front by tokenizing the raw CHECK text (skipping string literals). In the shared executors, so it guards both the simple and SQL surfaces and fixes a latent rename-drift bug that desynced the stored CHECK text and broke rebuild. - SQL DROP COLUMN refuses an index-covered column (no --cascade SQL spelling — matches SQLite + the simple default). - The column executors and do_add_index gained an internal-__rdbms_* guard (refuse as "no such table"), closing a pre-existing exposure on both surfaces. (do_change_column_type / do_add_constraint / do_add_relationship are a tracked follow-up.) - `alter` is advanced-only; AlterTableAction::AddColumn is boxed (clippy::large_enum_variant). Docs: ADR-0035 status + §13 4e; ADR README; requirements.md Q1. Plan: docs/plans/20260525-adr-0035-sql-ddl-4e.md. Tests: 1854 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
+21
-1
@@ -1574,10 +1574,30 @@ impl App {
|
||||
command: &Command,
|
||||
facts: crate::friendly::FailureContext,
|
||||
) -> crate::friendly::TranslateContext {
|
||||
use crate::dsl::{Command as C, IndexSelector, RelationshipSelector};
|
||||
use crate::dsl::{AlterTableAction, Command as C, IndexSelector, RelationshipSelector};
|
||||
use crate::friendly::{Operation, TranslateContext};
|
||||
let (operation, fallback_table, fallback_column) = match command {
|
||||
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
|
||||
// SQL `ALTER TABLE` routes engine/validation errors through
|
||||
// the operation matching its action, with the parsed table
|
||||
// (and column, where the action names one) — ADR-0035 §4e.
|
||||
C::SqlAlterTable { table, action } => match action {
|
||||
AlterTableAction::AddColumn(spec) => (
|
||||
Operation::AddColumn,
|
||||
Some(table.as_str()),
|
||||
Some(spec.name.as_str()),
|
||||
),
|
||||
AlterTableAction::DropColumn { column } => (
|
||||
Operation::DropColumn,
|
||||
Some(table.as_str()),
|
||||
Some(column.as_str()),
|
||||
),
|
||||
AlterTableAction::RenameColumn { old, .. } => (
|
||||
Operation::RenameColumn,
|
||||
Some(table.as_str()),
|
||||
Some(old.as_str()),
|
||||
),
|
||||
},
|
||||
C::SqlCreateTable { name, .. } => {
|
||||
(Operation::CreateTable, Some(name.as_str()), None)
|
||||
}
|
||||
|
||||
@@ -3132,10 +3132,12 @@ fn do_add_column(
|
||||
table: &str,
|
||||
column: &ColumnSpec,
|
||||
) -> Result<AddColumnResult, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
if matches!(column.ty, Type::Serial | Type::ShortId) {
|
||||
// ADR-0029 §6: a `serial` / `shortid` column auto-fills
|
||||
// its own values, so a separate `default` is ambiguous.
|
||||
if column.default.is_some() {
|
||||
// (`default_sql` is the advanced-mode raw form — ADR-0035 §4e.)
|
||||
if column.default.is_some() || column.default_sql.is_some() {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{name}` is a {ty} column — it auto-fills its own values, \
|
||||
so it cannot also carry a `default`.",
|
||||
@@ -3146,8 +3148,9 @@ fn do_add_column(
|
||||
// A CHECK on an auto-generated column is supported at
|
||||
// `create table` time; adding one to a `serial` /
|
||||
// `shortid` column afterwards is not (the auto-fill
|
||||
// rebuild path does not thread it).
|
||||
if column.check.is_some() {
|
||||
// rebuild path does not thread it). `check_sql` is the
|
||||
// advanced-mode raw form.
|
||||
if column.check.is_some() || column.check_sql.is_some() {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"a `check` constraint on the auto-generated column `{}` \
|
||||
can only be set when the table is created.",
|
||||
@@ -3158,9 +3161,14 @@ fn do_add_column(
|
||||
}
|
||||
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
|
||||
// or `CHECK`, and a `NOT NULL` column added that way must
|
||||
// carry a default — all route through the rebuild
|
||||
// primitive instead (ADR-0029 §6).
|
||||
if column.unique || column.check.is_some() || (column.not_null && column.default.is_none())
|
||||
// carry a default — all route through the rebuild primitive
|
||||
// instead (ADR-0029 §6). The advanced-mode raw forms
|
||||
// (`check_sql` / `default_sql`, ADR-0035 §4e) count alongside
|
||||
// the typed AST forms.
|
||||
if column.unique
|
||||
|| column.check.is_some()
|
||||
|| column.check_sql.is_some()
|
||||
|| (column.not_null && column.default.is_none() && column.default_sql.is_none())
|
||||
{
|
||||
do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
|
||||
} else {
|
||||
@@ -3370,7 +3378,12 @@ fn do_add_constrained_column_via_rebuild(
|
||||
|
||||
// ADR-0029 §6 pre-flight refusals — caught before any SQL
|
||||
// write, surfaced as friendly messages.
|
||||
if spec.not_null && spec.default.is_none() && row_count > 0 {
|
||||
// A default may come from the typed AST (`default`, simple mode) or
|
||||
// the raw advanced-mode text (`default_sql`, ADR-0035 §4e); either
|
||||
// satisfies the NOT-NULL backfill and triggers the UNIQUE collision
|
||||
// guard.
|
||||
let has_default = spec.default.is_some() || spec.default_sql.is_some();
|
||||
if spec.not_null && !has_default && row_count > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"adding the NOT NULL column `{}` to `{table}`, which already \
|
||||
has {row_count} row(s), needs a `default` — every existing \
|
||||
@@ -3378,7 +3391,7 @@ fn do_add_constrained_column_via_rebuild(
|
||||
spec.name,
|
||||
)));
|
||||
}
|
||||
if spec.unique && spec.default.is_some() && row_count > 1 {
|
||||
if spec.unique && has_default && row_count > 1 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"adding the UNIQUE column `{}` with a default to `{table}` \
|
||||
would give all {row_count} existing rows the same value, \
|
||||
@@ -3387,6 +3400,12 @@ fn do_add_constrained_column_via_rebuild(
|
||||
)));
|
||||
}
|
||||
|
||||
// The DEFAULT literal: the raw advanced-mode text wins over the typed
|
||||
// form (ADR-0035 §4e, mirroring `column_constraints_sql`).
|
||||
let default_sql = match &spec.default_sql {
|
||||
Some(raw) => Some(raw.clone()),
|
||||
None => default_sql_literal(spec)?,
|
||||
};
|
||||
// Append the new column to the schema; the rebuild's
|
||||
// column-by-name copy leaves it at its DEFAULT (or NULL).
|
||||
let mut new_schema = old_schema.clone();
|
||||
@@ -3396,16 +3415,17 @@ fn do_add_constrained_column_via_rebuild(
|
||||
notnull: spec.not_null,
|
||||
primary_key: false,
|
||||
unique: spec.unique,
|
||||
default_sql: default_sql_literal(spec)?,
|
||||
default_sql,
|
||||
check: None,
|
||||
user_type: Some(spec.ty),
|
||||
});
|
||||
// The CHECK is compiled against the post-add schema, so it
|
||||
// may reference the new column itself.
|
||||
// The CHECK: the raw advanced-mode text (`check_sql`) wins; otherwise
|
||||
// the typed `check` AST is compiled against the post-add schema (so
|
||||
// it may reference the new column itself).
|
||||
let check_sql = spec
|
||||
.check
|
||||
.as_ref()
|
||||
.map(|e| compile_check_sql(e, &new_schema));
|
||||
.check_sql
|
||||
.clone()
|
||||
.or_else(|| spec.check.as_ref().map(|e| compile_check_sql(e, &new_schema)));
|
||||
if let Some(last) = new_schema.columns.last_mut() {
|
||||
last.check.clone_from(&check_sql);
|
||||
}
|
||||
@@ -4006,6 +4026,7 @@ fn do_drop_column(
|
||||
column: &str,
|
||||
cascade: bool,
|
||||
) -> Result<DropColumnResult, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
let schema = read_schema(conn, table)?;
|
||||
let col_info = schema
|
||||
.columns
|
||||
@@ -4059,6 +4080,19 @@ fn do_drop_column(
|
||||
)));
|
||||
}
|
||||
|
||||
// A CHECK (table-level, or a *different* column's column-level CHECK)
|
||||
// that references this column (ADR-0035 §4e, the 4a.3 deferral): a
|
||||
// deliberate up-front refusal — dropping the column would break that
|
||||
// CHECK and the rebuilt DDL would name a missing column. The column's
|
||||
// own column-level CHECK drops with it, so it does not block.
|
||||
// Friendly wording is H1. Guards both surfaces.
|
||||
if column_referenced_by_check(conn, table, &schema, column, false)? {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"cannot drop `{table}.{column}` while a CHECK references it; \
|
||||
drop the constraint first."
|
||||
)));
|
||||
}
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
@@ -4115,6 +4149,7 @@ fn do_rename_column(
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
let schema = read_schema(conn, table)?;
|
||||
if !schema.columns.iter().any(|c| c.name == old) {
|
||||
return Err(DbError::Sqlite {
|
||||
@@ -4122,6 +4157,19 @@ fn do_rename_column(
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
});
|
||||
}
|
||||
// A CHECK that references this column (ADR-0035 §4e): refuse — a
|
||||
// native RENAME COLUMN rewrites the live CHECK but the stored CHECK
|
||||
// text (table-level in `__rdbms_playground_table_checks`, or
|
||||
// column-level in `__rdbms_playground_columns`) keeps the old name,
|
||||
// drifting the metadata and breaking a later rebuild. A column's
|
||||
// *own* column-level CHECK drifts too (`include_self = true`).
|
||||
// Deliberate refusal (friendly wording is H1); guards both surfaces.
|
||||
if column_referenced_by_check(conn, table, &schema, old, true)? {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"cannot rename `{table}.{old}` while a CHECK references it; \
|
||||
drop the constraint first."
|
||||
)));
|
||||
}
|
||||
if old == new {
|
||||
// Nothing to do; refusing keeps behaviour
|
||||
// predictable rather than appearing to "succeed"
|
||||
@@ -5213,6 +5261,107 @@ fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbEr
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether the raw CHECK expression `check_expr` references the column
|
||||
/// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard).
|
||||
///
|
||||
/// Tokenizes the expression with the shared lex helpers, **skipping
|
||||
/// single-quoted string literals** so an identifier that only appears
|
||||
/// inside a literal does not false-match, and compares each bare
|
||||
/// identifier case-insensitively. Playground column names are always
|
||||
/// valid bare identifiers (`validate_user_name` rejects the characters
|
||||
/// that would force quoting), so bare-identifier scanning is sufficient;
|
||||
/// the engine's native `DROP`/`RENAME COLUMN` remains the backstop for
|
||||
/// any miss.
|
||||
fn check_references_column(check_expr: &str, column: &str) -> bool {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal, skip_whitespace};
|
||||
let mut i = 0;
|
||||
while i < check_expr.len() {
|
||||
i = skip_whitespace(check_expr, i);
|
||||
if i >= check_expr.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(((_, end), _)) = consume_string_literal(check_expr, i) {
|
||||
i = end;
|
||||
} else if let Some((start, end)) = consume_ident(check_expr, i) {
|
||||
if check_expr[start..end].eq_ignore_ascii_case(column) {
|
||||
return true;
|
||||
}
|
||||
i = end;
|
||||
} else {
|
||||
// Operator / paren / number / punctuation — advance one char.
|
||||
i += check_expr[i..].chars().next().map_or(1, char::len_utf8);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod check_references_column_tests {
|
||||
use super::check_references_column;
|
||||
|
||||
#[test]
|
||||
fn detects_a_bare_identifier() {
|
||||
assert!(check_references_column("price > 0", "price"));
|
||||
assert!(check_references_column("a < b AND b < c", "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_case_insensitive() {
|
||||
assert!(check_references_column("Price > 0", "price"));
|
||||
assert!(check_references_column("price > 0", "PRICE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_identifier_inside_a_string_literal() {
|
||||
// `status` appears only inside a literal → not a reference.
|
||||
assert!(!check_references_column("kind <> 'status'", "status"));
|
||||
// A genuine reference alongside a literal is still found.
|
||||
assert!(check_references_column("status <> 'archived'", "status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_a_longer_identifier_that_merely_contains_the_name() {
|
||||
// `price` is a substring of these idents but not a whole match.
|
||||
assert!(!check_references_column("price_cents > 0", "price"));
|
||||
assert!(!check_references_column("unit_price > 0", "price"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any CHECK constraint on `table` references `column` — both the
|
||||
/// table-level CHECKs (`read_table_checks`) and the column-level CHECKs
|
||||
/// (`schema.columns[].check`), the guard for drop/rename column
|
||||
/// (ADR-0035 §4e).
|
||||
///
|
||||
/// `include_self` controls whether the column's *own* column-level CHECK
|
||||
/// counts: a RENAME rewrites the live CHECK but leaves the stored text
|
||||
/// stale (drift) even for a self-check, so it must be included; a DROP
|
||||
/// removes the column's own CHECK alongside it, so it is excluded (only a
|
||||
/// *cross-referencing* CHECK blocks the drop).
|
||||
fn column_referenced_by_check(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
schema: &ReadSchema,
|
||||
column: &str,
|
||||
include_self: bool,
|
||||
) -> Result<bool, DbError> {
|
||||
for expr in read_table_checks(conn, table)? {
|
||||
if check_references_column(&expr, column) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
for col in &schema.columns {
|
||||
if !include_self && col.name == column {
|
||||
continue;
|
||||
}
|
||||
if let Some(check) = &col.check
|
||||
&& check_references_column(check, column)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Read the user-created indexes on `table` (ADR-0025).
|
||||
///
|
||||
/// `pragma_index_list` reports every index; we keep only those
|
||||
@@ -6042,6 +6191,23 @@ fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> St
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse an internal `__rdbms_*` table as "no such table" — the same
|
||||
/// opacity the rest of the app presents (internal tables are filtered
|
||||
/// from `list_tables` and never offered in completion). Guards the
|
||||
/// user-facing schema-mutation executors so a deliberately-typed
|
||||
/// internal name cannot index or alter the metadata tables (ADR-0035
|
||||
/// §4d/§4e; the grammar's `reject_internal_table` covers only the typed
|
||||
/// SQL family, not the simple DSL nodes).
|
||||
fn reject_internal_table_name(table: &str) -> Result<(), DbError> {
|
||||
if table.to_ascii_lowercase().starts_with("__rdbms_") {
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such table: {table}"),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
|
||||
///
|
||||
/// `user_only = true` counts only explicit `CREATE INDEX` objects
|
||||
@@ -6072,19 +6238,10 @@ fn do_add_index(
|
||||
columns: &[String],
|
||||
unique: bool,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
// 0. Internal `__rdbms_*` tables are not user tables (they are
|
||||
// filtered from `list_tables` and never offered in completion), so
|
||||
// indexing one is refused as "no such table" — the same opacity
|
||||
// the rest of the app presents. Guards BOTH the simple `add index`
|
||||
// and the SQL `CREATE INDEX` surfaces, since both reach here
|
||||
// (ADR-0025 / ADR-0035 §4d; the grammar's `reject_internal_table`
|
||||
// only covers the typed SQL family, not the simple node).
|
||||
if table.to_ascii_lowercase().starts_with("__rdbms_") {
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such table: {table}"),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
});
|
||||
}
|
||||
// 0. Internal tables are not user tables (ADR-0025 / ADR-0035 §4d) —
|
||||
// refused on both the simple `add index` and SQL `CREATE INDEX`
|
||||
// surfaces, which both reach here.
|
||||
reject_internal_table_name(table)?;
|
||||
// 1. Table must exist; gather its columns.
|
||||
let schema = read_schema(conn, table)?;
|
||||
// 2. Every indexed column must exist on the table.
|
||||
|
||||
@@ -301,6 +301,15 @@ pub enum Command {
|
||||
unique: bool,
|
||||
if_not_exists: bool,
|
||||
},
|
||||
/// Advanced-mode SQL `ALTER TABLE <table> <action>` (ADR-0035 §4,
|
||||
/// sub-phase 4e). `alter` is advanced-only. Each action maps to an
|
||||
/// existing column executor — the runtime decomposes it to
|
||||
/// `add_column` / `drop_column` / `rename_column` (one undo step
|
||||
/// each). 4f/4g/4h extend [`AlterTableAction`].
|
||||
SqlAlterTable {
|
||||
table: String,
|
||||
action: AlterTableAction,
|
||||
},
|
||||
/// Add a column-level constraint to an existing column
|
||||
/// (ADR-0029 §2.2). Applied through the rebuild-table
|
||||
/// primitive after a §5 dry-run guards populated columns.
|
||||
@@ -705,6 +714,25 @@ pub enum IndexSelector {
|
||||
Columns { table: String, columns: Vec<String> },
|
||||
}
|
||||
|
||||
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4). Sub-phase
|
||||
/// 4e carries the column actions; 4f/4g/4h add `AlterColumnType`,
|
||||
/// `AddConstraint`/`AddForeignKey`/`DropConstraint`, and `RenameTo`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AlterTableAction {
|
||||
/// `ADD COLUMN <name> <type> [NOT NULL] [UNIQUE] [DEFAULT …]
|
||||
/// [CHECK …]` — column constraints only (no PK / inline REFERENCES;
|
||||
/// those are create-table / 4g). Reuses `do_add_column`. Boxed so
|
||||
/// the large `ColumnSpec` doesn't bloat the enum (and `Command` /
|
||||
/// `Action` that embed it) — `clippy::large_enum_variant`.
|
||||
AddColumn(Box<ColumnSpec>),
|
||||
/// `DROP COLUMN <name>` — reuses `do_drop_column` (cascade = false:
|
||||
/// an index-covered column is refused, matching SQLite + the
|
||||
/// simple-mode default; there is no `--cascade` SQL spelling).
|
||||
DropColumn { column: String },
|
||||
/// `RENAME COLUMN <old> TO <new>` — reuses `do_rename_column`.
|
||||
RenameColumn { old: String, new: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IndexSelector {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -735,6 +763,7 @@ impl Command {
|
||||
Self::DropIndex { .. } => "drop index",
|
||||
Self::SqlDropIndex { .. } => "drop index",
|
||||
Self::SqlCreateIndex { .. } => "create index",
|
||||
Self::SqlAlterTable { .. } => "alter table",
|
||||
Self::AddConstraint { .. } => "add constraint",
|
||||
Self::DropConstraint { .. } => "drop constraint",
|
||||
Self::ShowTable { .. } => "show table",
|
||||
@@ -813,6 +842,7 @@ impl Command {
|
||||
// `DropIndex` / `SqlDropTable` fallback).
|
||||
Self::SqlDropIndex { name, .. } => name,
|
||||
Self::SqlCreateIndex { table, .. } => table,
|
||||
Self::SqlAlterTable { table, .. } => table,
|
||||
// Replay isn't tied to a single table; the path is
|
||||
// the most identifying thing for log output.
|
||||
Self::Replay { path } => path,
|
||||
|
||||
+279
-2
@@ -13,8 +13,8 @@
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector,
|
||||
RelationshipSelector, SqlForeignKey,
|
||||
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
||||
IndexSelector, RelationshipSelector, SqlForeignKey,
|
||||
};
|
||||
use crate::dsl::value::Value;
|
||||
use crate::dsl::grammar::{
|
||||
@@ -1841,6 +1841,182 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// SQL `ALTER TABLE <T> <action>` (ADR-0035 §4, sub-phase 4e).
|
||||
// `alter` is an advanced-*only* entry word (like `select`/`with`).
|
||||
// Actions: ADD/DROP/RENAME COLUMN — the `COLUMN` keyword is required
|
||||
// (reserves bare `RENAME TO` for 4h and `ADD CONSTRAINT` for 4g).
|
||||
// =================================================================
|
||||
|
||||
// The ALTER table slot carries the SQL-family `reject_internal_table`
|
||||
// validator (parse-time refusal; the executors guard the rest) and
|
||||
// `writes_table` so the DROP/RENAME column slot narrows to its columns.
|
||||
const AT_TABLE_NAME: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "table_name",
|
||||
validator: Some(super::sql_select::reject_internal_table),
|
||||
highlight_override: None,
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
// ADD COLUMN's constraint suffix — the SQL leaf nodes for NOT NULL /
|
||||
// UNIQUE / DEFAULT / CHECK only. PK and inline REFERENCES are
|
||||
// deliberately excluded (PK is invalid on ADD COLUMN; REFERENCES is 4g).
|
||||
static AT_ADD_CONSTRAINT_CHOICES: &[Node] = &[
|
||||
Node::Seq(super::sql_create_table::NOT_NULL_NODES),
|
||||
Node::Word(Word::keyword("unique")),
|
||||
Node::Seq(super::sql_create_table::DEFAULT_NODES),
|
||||
Node::Seq(super::sql_create_table::CHECK_NODES),
|
||||
];
|
||||
const AT_ADD_CONSTRAINT: Node = Node::Choice(AT_ADD_CONSTRAINT_CHOICES);
|
||||
const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||||
inner: &AT_ADD_CONSTRAINT,
|
||||
separator: None,
|
||||
min: 0,
|
||||
};
|
||||
|
||||
static AT_ADD_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("add")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
super::sql_create_table::COL_NAME,
|
||||
super::sql_create_table::SQL_TYPE,
|
||||
AT_ADD_CONSTRAINT_SUFFIX,
|
||||
];
|
||||
const AT_ADD_COLUMN: Node = Node::Seq(AT_ADD_COLUMN_NODES);
|
||||
|
||||
static AT_DROP_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("drop")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
COLUMN_NAME,
|
||||
];
|
||||
const AT_DROP_COLUMN: Node = Node::Seq(AT_DROP_COLUMN_NODES);
|
||||
|
||||
static AT_RENAME_COLUMN_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("rename")),
|
||||
Node::Word(Word::keyword("column")),
|
||||
COLUMN_NAME,
|
||||
Node::Word(Word::keyword("to")),
|
||||
NEW_COLUMN_NAME,
|
||||
];
|
||||
const AT_RENAME_COLUMN: Node = Node::Seq(AT_RENAME_COLUMN_NODES);
|
||||
|
||||
// Each action branch leads on a concrete keyword (`add`/`drop`/
|
||||
// `rename`) — trap-safe.
|
||||
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN];
|
||||
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
|
||||
|
||||
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("table")),
|
||||
AT_TABLE_NAME,
|
||||
AT_ACTION,
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
const SQL_ALTER_TABLE_SHAPE: Node = Node::Seq(SQL_ALTER_TABLE_SHAPE_NODES);
|
||||
|
||||
/// Build the single `ColumnSpec` for an `ALTER TABLE … ADD COLUMN`
|
||||
/// (ADR-0035 §4e). Mirrors the SQL `CREATE TABLE` per-column extraction
|
||||
/// for one column: DEFAULT/CHECK are captured as **raw text** by byte
|
||||
/// span (`sql_expr` builds no AST — 4a.2), so the executor consumes
|
||||
/// `default_sql`/`check_sql`.
|
||||
fn build_alter_add_column_spec(
|
||||
path: &MatchedPath,
|
||||
source: &str,
|
||||
) -> Result<ColumnSpec, ValidationError> {
|
||||
let mut spec: Option<ColumnSpec> = None;
|
||||
let mut pending_name: Option<String> = None;
|
||||
let mut items = path.items.iter().peekable();
|
||||
while let Some(item) = items.next() {
|
||||
match &item.kind {
|
||||
MatchedKind::Ident { role: "col_name", .. } => {
|
||||
pending_name = Some(item.text.clone());
|
||||
}
|
||||
MatchedKind::Ident { role: "col_type", .. } => {
|
||||
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
})?;
|
||||
let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||||
spec = Some(ColumnSpec::new(name, ty));
|
||||
}
|
||||
MatchedKind::Word("double") => {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("precision"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
|
||||
spec = Some(ColumnSpec::new(name, Type::Real));
|
||||
}
|
||||
MatchedKind::Word("not") => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
|
||||
items.next();
|
||||
if let Some(s) = spec.as_mut() {
|
||||
s.not_null = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
MatchedKind::Word("unique") => {
|
||||
if let Some(s) = spec.as_mut() {
|
||||
s.unique = true;
|
||||
}
|
||||
}
|
||||
MatchedKind::Word("default") => {
|
||||
if let Some((start, end)) = capture_expr_span(&mut items)
|
||||
&& let Some(s) = spec.as_mut()
|
||||
{
|
||||
s.default_sql = Some(source[start..end].trim().to_string());
|
||||
}
|
||||
}
|
||||
MatchedKind::Word("check") => {
|
||||
if let Some((start, end)) = capture_parenthesised_span(&mut items)
|
||||
&& let Some(s) = spec.as_mut()
|
||||
{
|
||||
s.check_sql = Some(source[start..end].trim().to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
spec.ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "add column needs a name and type".to_string())],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build `Command::SqlAlterTable` (ADR-0035 §4e). The action is the
|
||||
/// leading concrete keyword (`add`/`drop`/`rename` — exactly one matches
|
||||
/// per the action `Choice`).
|
||||
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
let table = require_ident(path, "table_name")?;
|
||||
let action = if path.contains_word("add") {
|
||||
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
|
||||
} else if path.contains_word("rename") {
|
||||
AlterTableAction::RenameColumn {
|
||||
old: require_ident(path, "column_name")?,
|
||||
new: require_ident(path, "new_column_name")?,
|
||||
}
|
||||
} else {
|
||||
AlterTableAction::DropColumn {
|
||||
column: require_ident(path, "column_name")?,
|
||||
}
|
||||
};
|
||||
Ok(Command::SqlAlterTable { table, action })
|
||||
}
|
||||
|
||||
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("alter"),
|
||||
shape: SQL_ALTER_TABLE_SHAPE,
|
||||
ast_builder: build_sql_alter_table,
|
||||
help_id: Some("ddl.sql_alter_table"),
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||||
// =================================================================
|
||||
@@ -2293,3 +2469,104 @@ mod sql_create_index_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sql_alter_table_tests {
|
||||
use crate::dsl::command::{AlterTableAction, ColumnSpec, Command};
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
fn alter(input: &str) -> (String, AlterTableAction) {
|
||||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||
Command::SqlAlterTable { table, action } => (table, action),
|
||||
other => panic!("expected SqlAlterTable, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn added_spec(input: &str) -> ColumnSpec {
|
||||
match alter(input).1 {
|
||||
AlterTableAction::AddColumn(spec) => *spec,
|
||||
other => panic!("expected AddColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_plain() {
|
||||
let (table, action) = alter("alter table T add column note text");
|
||||
assert_eq!(table, "T");
|
||||
match action {
|
||||
AlterTableAction::AddColumn(spec) => {
|
||||
assert_eq!(spec.name, "note");
|
||||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||||
assert!(!spec.not_null && !spec.unique);
|
||||
assert!(spec.default_sql.is_none() && spec.check_sql.is_none());
|
||||
}
|
||||
other => panic!("expected AddColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_not_null_and_unique() {
|
||||
let spec = added_spec("alter table T add column code text not null unique");
|
||||
assert!(spec.not_null && spec.unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_default_and_check_capture_raw_text() {
|
||||
// DEFAULT / CHECK are captured as raw SQL text (sql_expr is
|
||||
// validate-only) — ADR-0035 §4e.
|
||||
let spec = added_spec("alter table T add column qty int default 0 check (qty >= 0)");
|
||||
assert_eq!(spec.default_sql.as_deref(), Some("0"));
|
||||
assert_eq!(spec.check_sql.as_deref(), Some("qty >= 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_accepts_sql_type_alias() {
|
||||
// `varchar(255)` → text, length discarded (ADR-0035 §3).
|
||||
let spec = added_spec("alter table T add column name varchar(255)");
|
||||
assert_eq!(spec.ty, crate::dsl::types::Type::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_column() {
|
||||
match alter("alter table T drop column note").1 {
|
||||
AlterTableAction::DropColumn { column } => assert_eq!(column, "note"),
|
||||
other => panic!("expected DropColumn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_column() {
|
||||
match alter("alter table T rename column a to b").1 {
|
||||
AlterTableAction::RenameColumn { old, new } => {
|
||||
assert_eq!(old, "a");
|
||||
assert_eq!(new, "b");
|
||||
}
|
||||
other => panic!("expected RenameColumn, got {other:?}"),
|
||||
}
|
||||
// trailing semicolon tolerated
|
||||
assert!(matches!(
|
||||
alter("alter table T rename column a to b;").1,
|
||||
AlterTableAction::RenameColumn { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alter_is_advanced_only() {
|
||||
// No simple `alter`; in simple mode it does not parse as a
|
||||
// command (the dispatcher emits the "this is SQL" hint).
|
||||
assert!(parse_command_in_mode("alter table T drop column c", Mode::Simple).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_table_is_rejected_at_parse() {
|
||||
// The ALTER table slot carries `reject_internal_table`.
|
||||
assert!(
|
||||
parse_command_in_mode(
|
||||
"alter table __rdbms_playground_columns drop column table_name",
|
||||
Mode::Advanced
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,10 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
// `create [unique] index …` → SQL_CREATE_INDEX).
|
||||
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
||||
(&ddl::SQL_CREATE_INDEX, CommandCategory::Advanced),
|
||||
// `alter` is a new advanced-*only* DDL entry word (ADR-0035 §2/§4e),
|
||||
// like `select`/`with` — no simple node, so `is_advanced_only` is
|
||||
// true and simple-mode `alter …` gets the "this is SQL" hint.
|
||||
(&ddl::SQL_ALTER_TABLE, CommandCategory::Advanced),
|
||||
// Shared `drop` entry word: `ddl::DROP` (simple) and these advanced
|
||||
// SQL nodes. SQL-first in advanced mode; `drop table [if exists] T`
|
||||
// → SQL_DROP_TABLE, `drop index [if exists] <name>` → SQL_DROP_INDEX
|
||||
|
||||
@@ -95,11 +95,11 @@ static SQL_TYPE_CHOICES: &[Node] = &[
|
||||
Node::Seq(TYPE_WITH_LENGTH_NODES),
|
||||
];
|
||||
/// `double precision | <type-keyword-or-alias> [ '(' n [, n] ')' ]`.
|
||||
const SQL_TYPE: Node = Node::Choice(SQL_TYPE_CHOICES);
|
||||
pub(crate) const SQL_TYPE: Node = Node::Choice(SQL_TYPE_CHOICES);
|
||||
|
||||
// --- Column-level constraints (4a clean-reuse set only) -----------
|
||||
|
||||
static NOT_NULL_NODES: &[Node] = &[
|
||||
pub(crate) static NOT_NULL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Word(Word::keyword("null")),
|
||||
];
|
||||
@@ -132,8 +132,8 @@ static DEFAULT_VALUE_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("false")),
|
||||
];
|
||||
const DEFAULT_VALUE: Node = Node::Choice(DEFAULT_VALUE_CHOICES);
|
||||
static DEFAULT_NODES: &[Node] = &[Node::Word(Word::keyword("default")), DEFAULT_VALUE];
|
||||
static CHECK_NODES: &[Node] = &[
|
||||
pub(crate) static DEFAULT_NODES: &[Node] = &[Node::Word(Word::keyword("default")), DEFAULT_VALUE];
|
||||
pub(crate) static CHECK_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("check")),
|
||||
Node::Punct('('),
|
||||
Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
|
||||
@@ -217,7 +217,7 @@ const COL_CONSTRAINT_SUFFIX: Node = Node::Repeated {
|
||||
|
||||
// --- Column definition: `<name> <type> [constraints…]` ------------
|
||||
|
||||
const COL_NAME: Node = Node::Ident {
|
||||
pub(crate) const COL_NAME: Node = Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "col_name",
|
||||
validator: None,
|
||||
|
||||
+3
-2
@@ -20,8 +20,9 @@ pub mod walker;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{
|
||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, Expr, IndexSelector,
|
||||
MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter, SqlForeignKey,
|
||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, Expr,
|
||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||
SqlForeignKey,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
|
||||
@@ -175,6 +175,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.ddl.sql_drop_table", &[]),
|
||||
("help.ddl.sql_create_index", &[]),
|
||||
("help.ddl.sql_drop_index", &[]),
|
||||
("help.ddl.sql_alter_table", &[]),
|
||||
// Advanced-mode SQL CREATE TABLE / DROP TABLE no-op notes (ADR-0035 §4).
|
||||
("ddl.create_skipped_exists", &["name"]),
|
||||
("ddl.drop_skipped_absent", &["name"]),
|
||||
@@ -255,6 +256,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.sql_drop_table", &[]),
|
||||
("parse.usage.sql_create_index", &[]),
|
||||
("parse.usage.sql_drop_index", &[]),
|
||||
("parse.usage.sql_alter_table", &[]),
|
||||
("parse.usage.delete", &[]),
|
||||
("parse.usage.drop_column", &[]),
|
||||
("parse.usage.drop_constraint", &[]),
|
||||
|
||||
@@ -270,6 +270,10 @@ help:
|
||||
— create an index (advanced SQL)
|
||||
sql_drop_index: |-
|
||||
drop index [if exists] <name> — remove an index (advanced SQL)
|
||||
sql_alter_table: |-
|
||||
alter table <T> add column <col> <type> [not null] [unique] [default …] [check …]
|
||||
alter table <T> drop column <col>
|
||||
alter table <T> rename column <old> to <new> — change a table's columns (advanced SQL)
|
||||
drop: |-
|
||||
drop table <T> — remove a table
|
||||
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
||||
@@ -465,6 +469,10 @@ parse:
|
||||
sql_drop_table: "drop table [if exists] <Name>"
|
||||
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
|
||||
sql_drop_index: "drop index [if exists] <Name>"
|
||||
sql_alter_table: |-
|
||||
alter table <Table> add column <Name> <Type> [not null] [unique] [default <expr>] [check (<expr>)]
|
||||
alter table <Table> drop column <Name>
|
||||
alter table <Table> rename column <Old> to <New>
|
||||
drop_table: "drop table <Name>"
|
||||
drop_column: "drop column [from] [table] <Table>: <Name>"
|
||||
drop_relationship: |-
|
||||
|
||||
+21
-1
@@ -33,7 +33,7 @@ use crate::db::{
|
||||
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
||||
QueryPlan, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::{Command, ColumnSpec};
|
||||
use crate::dsl::{AlterTableAction, Command, ColumnSpec};
|
||||
use crate::dsl::walker::Severity;
|
||||
use crate::event::AppEvent;
|
||||
use crate::project::{
|
||||
@@ -2095,6 +2095,26 @@ async fn execute_command_typed(
|
||||
CreateIndexOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
|
||||
CreateIndexOutcome::Skipped(n) => CommandOutcome::SchemaCreateIndexSkipped(n),
|
||||
}),
|
||||
// `ALTER TABLE` (ADR-0035 §4e) decomposes to the existing column
|
||||
// executors — each already one snapshot (one undo step) and
|
||||
// journalled. No new worker layer; the outcomes reuse the
|
||||
// simple-mode add/drop/rename column paths.
|
||||
Command::SqlAlterTable { table, action } => match action {
|
||||
AlterTableAction::AddColumn(spec) => database
|
||||
.add_column(table, *spec, src)
|
||||
.await
|
||||
.map(CommandOutcome::AddColumn),
|
||||
// cascade = false: an index-covered column is refused (no SQL
|
||||
// `--cascade` spelling), matching SQLite + the simple default.
|
||||
AlterTableAction::DropColumn { column } => database
|
||||
.drop_column(table, column, false, src)
|
||||
.await
|
||||
.map(CommandOutcome::DropColumn),
|
||||
AlterTableAction::RenameColumn { old, new } => database
|
||||
.rename_column(table, old, new, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
},
|
||||
Command::AddConstraint {
|
||||
table,
|
||||
column,
|
||||
|
||||
Reference in New Issue
Block a user