add column: column constraints — NOT NULL / UNIQUE / DEFAULT (ADR-0029 §6)

`add column` now accepts the shared constraint suffix and the
worker honours it — the surface where NOT NULL / UNIQUE
actually matter, on non-PK columns.

- Grammar: `ADD_COLUMN_NODES` gains the constraint-suffix
  fragment; `collect_column_constraints` folds it into
  `Command::AddColumn`.
- `do_add_column` routes per ADR-0029 §6: SQLite's `ALTER
  TABLE ADD COLUMN` cannot express `UNIQUE` and requires a
  default for `NOT NULL`, so those go through the rebuild
  primitive (`do_add_constrained_column_via_rebuild`); plain
  cases keep the ALTER path with the constraint suffix
  appended.
- Pre-flight refusals, before any SQL write: a NOT NULL
  column with no default added to a populated table; a UNIQUE
  column with a default added to a multi-row table; a default
  on a `serial` / `shortid` column.

CHECK is still deferred to the next commit. 1193 tests pass
(+9); clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 14:50:19 +00:00
parent 12395a9a6c
commit 58d8958822
2 changed files with 330 additions and 17 deletions
+80 -5
View File
@@ -15,6 +15,7 @@ use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
@@ -299,6 +300,9 @@ const ADD_COLUMN_NODES: &[Node] = &[
Node::Punct('('),
TYPE_SLOT,
Node::Punct(')'),
// ADR-0029: the constraint suffix — shared with `create
// table`'s column spec.
COLUMN_CONSTRAINT_SUFFIX,
];
const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
@@ -612,15 +616,15 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
ty,
// Constraint suffix is wired in once the
// constraint grammar lands (ADR-0029).
not_null: false,
unique: false,
default: None,
not_null,
unique,
default,
// CHECK joins in a later ADR-0029 step.
check: None,
})
}
@@ -890,6 +894,47 @@ const CREATE_TABLE_NODES: &[Node] = &[
];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);
/// Collect the ADR-0029 constraint suffix from a
/// single-column command's matched path (`add column`),
/// returning the `(not_null, unique, default)` triple. The
/// scan reacts only to the four constraint keywords, so
/// passing the whole path is safe. (`create table`'s
/// multi-column collection is inline in `build_create_table`.)
fn collect_column_constraints(
path: &MatchedPath,
) -> Result<(bool, bool, Option<Value>), ValidationError> {
let mut not_null = false;
let mut unique = false;
let mut default = None;
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Word("not") => {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
not_null = true;
}
}
MatchedKind::Word("unique") => unique = true,
MatchedKind::Word("default") => {
let value = items
.next()
.and_then(crate::dsl::grammar::data::item_to_value)
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "default needs a value".to_string())],
})?;
default = Some(value);
}
_ => {}
}
}
Ok((not_null, unique, default))
}
/// The friendly error for declaring a constraint a
/// primary-key column already implies (ADR-0029 §9).
fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError {
@@ -1077,4 +1122,34 @@ mod constraint_tests {
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
}
#[test]
fn add_column_parses_its_constraint_suffix() {
match parse_command("add column to T: tier (int) not null default 0")
.expect("add column should parse")
{
Command::AddColumn {
not_null,
unique,
default,
..
} => {
assert!(not_null);
assert!(!unique);
assert_eq!(default, Some(Value::Number("0".to_string())));
}
other => panic!("expected AddColumn, got {other:?}"),
}
}
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
assert!(unique);
assert!(!not_null);
}
other => panic!("expected AddColumn, got {other:?}"),
}
}
}