constraints: add constraint / drop constraint on existing columns (ADR-0029 §2.2)

Adds the two commands for modifying a column's constraints after
creation, completing ADR-0029's §2.2 surface.

Grammar (dsl/grammar/ddl.rs): `add constraint <constraint> to
<T>.<col>` reuses the §2.1 COLUMN_CONSTRAINT choice; `drop
constraint <kind> from <T>.<col>` names only the kind. Both join
the `add` / `drop` choices, discriminated by the `constraint`
form word.

AST (dsl/command.rs): `Command::AddConstraint` / `DropConstraint`
plus the `Constraint` / `ConstraintKind` enums.

Worker (db.rs): `do_add_constraint` / `do_drop_constraint` apply
the change through the rebuild-table primitive. `add` runs the §5
dry-run first — `not null` / `unique` / `check` against a
populated column are refused, before any write, with a
pretty-printed table of offending rows. §9 redundant-on-PK
declarations and §6 `default` on an auto-generated column are
friendly refusals; dropping a constraint the column does not
carry is likewise refused.

Also fixes schema_to_ddl, which suppressed UNIQUE for every PK
column — a compound-PK member is not individually unique, so an
explicit UNIQUE on it must survive the rebuild.

23 tests added (6 grammar, 17 worker); 3 completion-test and 3
matrix snapshots updated for the new `constraint` subcommand.
This commit is contained in:
claude@clouddev1
2026-05-19 18:31:57 +00:00
parent 102dff08c4
commit abce1188f2
14 changed files with 1360 additions and 29 deletions
+212 -7
View File
@@ -13,7 +13,8 @@
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, Expr, IndexSelector, RelationshipSelector,
ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector,
RelationshipSelector,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
@@ -283,7 +284,8 @@ const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX];
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -410,7 +412,7 @@ const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);
// add entry — `add (column|1:n relationship|index) …`
// =================================================================
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX];
const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX, ADD_CONSTRAINT];
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
// =================================================================
@@ -564,6 +566,7 @@ fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
})
}
}
Some("constraint") => build_drop_constraint(path),
Some("relationship") => {
// Endpoints form has `from` as the third Word.
let has_from = path
@@ -634,6 +637,7 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
table: require_ident(path, "table_name")?,
columns: collect_idents(path, "column_name"),
}),
Some("constraint") => build_add_constraint(path),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown add subcommand".to_string())],
@@ -764,6 +768,72 @@ fn build_change_column(path: &MatchedPath) -> Result<Command, ValidationError> {
})
}
/// Build an `add constraint <constraint> to <T>.<col>` command
/// (ADR-0029 §2.2). The `<constraint>` reuses the §2.1
/// `COLUMN_CONSTRAINT` Choice, so exactly one of the four
/// constraint kinds is matched; `collect_column_constraints`
/// recovers it. The §9 redundancy and §5 dry-run checks are
/// execution-time (the parser has no schema) and live in the
/// database worker.
fn build_add_constraint(path: &MatchedPath) -> Result<Command, ValidationError> {
let (not_null, unique, default, check) = collect_column_constraints(path)?;
let constraint = if not_null {
Constraint::NotNull
} else if unique {
Constraint::Unique
} else if let Some(value) = default {
Constraint::Default(value)
} else if let Some(expr) = check {
Constraint::Check(expr)
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "add constraint needs a constraint".to_string())],
});
};
Ok(Command::AddConstraint {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
constraint,
})
}
/// Build a `drop constraint <kind> from <T>.<col>` command
/// (ADR-0029 §2.2). `drop` names only the kind — the
/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind
/// is recovered from which keyword(s) the path matched.
fn build_drop_constraint(path: &MatchedPath) -> Result<Command, ValidationError> {
let words: Vec<&'static str> = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.collect();
// `not` appears only in the `not null` Seq, so its presence
// alone identifies the kind.
let kind = if words.contains(&"not") {
ConstraintKind::NotNull
} else if words.contains(&"unique") {
ConstraintKind::Unique
} else if words.contains(&"default") {
ConstraintKind::Default
} else if words.contains(&"check") {
ConstraintKind::Check
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
});
};
Ok(Command::DropConstraint {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
kind,
})
}
// =================================================================
// CommandNodes
// =================================================================
@@ -778,6 +848,7 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_column",
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
pub static ADD: CommandNode = CommandNode {
@@ -789,6 +860,7 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_column",
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
pub static RENAME: CommandNode = CommandNode {
@@ -825,9 +897,10 @@ const COL_NAME: Node = Node::Hinted {
};
// ADR-0029 column-constraint suffix — `not null`, `unique`,
// `default <literal>`. (`check (<expr>)` joins in a later
// ADR-0029 step.) One shared fragment: `create table` uses it
// here; `add column` and `add constraint` reuse it later.
// `default <literal>`, `check (<expr>)`. One shared fragment:
// `create table` uses it here, `add column` reuses it as its
// type suffix, and `add constraint` reuses the individual
// `COLUMN_CONSTRAINT` Choice for its constraint slot.
const NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
@@ -867,6 +940,53 @@ const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated {
min: 0,
};
// =================================================================
// add_constraint / drop_constraint — `add constraint <constraint>
// to <T>.<col>` / `drop constraint <kind> from <T>.<col>`
// (ADR-0029 §2.2)
// =================================================================
// Payload-free keyword nodes for `drop constraint` — naming the
// kind is enough, since at most one constraint of each kind
// exists per column. `not null` / `unique` reuse the §2.1
// keyword-only nodes; `default` / `check` need bare-keyword
// variants here (their §2.1 forms carry a literal / expression
// payload that `drop` does not take).
const DROP_DEFAULT_KEYWORD: Node = Node::Word(Word::keyword("default"));
const DROP_CHECK_KEYWORD: Node = Node::Word(Word::keyword("check"));
const DROP_CONSTRAINT_KIND_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DROP_DEFAULT_KEYWORD,
DROP_CHECK_KEYWORD,
];
const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// The dotted `<Table>.<column>` target — the same `Ident '.'
// Ident` shape `add 1:n relationship` uses for its endpoints.
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("constraint")),
COLUMN_CONSTRAINT,
Node::Word(Word::keyword("to")),
CONSTRAINT_TARGET,
];
const ADD_CONSTRAINT: Node = Node::Seq(ADD_CONSTRAINT_NODES);
const DROP_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("constraint")),
DROP_CONSTRAINT_KIND,
Node::Word(Word::keyword("from")),
CONSTRAINT_TARGET,
];
const DROP_CONSTRAINT: Node = Node::Seq(DROP_CONSTRAINT_NODES);
const COL_SPEC_NODES: &[Node] = &[
COL_NAME,
Node::Punct('('),
@@ -1117,7 +1237,7 @@ pub static CREATE: CommandNode = CommandNode {
#[cfg(test)]
mod constraint_tests {
use super::Command;
use super::{Command, Constraint, ConstraintKind};
use crate::dsl::command::ColumnSpec;
use crate::dsl::parser::parse_command;
use crate::dsl::value::Value;
@@ -1234,4 +1354,89 @@ mod constraint_tests {
);
assert!(cols[0].check.is_some());
}
// --- `add constraint` / `drop constraint` (ADR-0029 §2.2) ---
#[test]
fn add_constraint_not_null_parses() {
match parse_command("add constraint not null to Users.email").expect("parse") {
Command::AddConstraint {
table,
column,
constraint,
} => {
assert_eq!(table, "Users");
assert_eq!(column, "email");
assert_eq!(constraint, Constraint::NotNull);
}
other => panic!("expected AddConstraint, got {other:?}"),
}
}
#[test]
fn add_constraint_unique_parses() {
match parse_command("add constraint unique to Users.email").expect("parse") {
Command::AddConstraint { constraint, .. } => {
assert_eq!(constraint, Constraint::Unique);
}
other => panic!("expected AddConstraint, got {other:?}"),
}
}
#[test]
fn add_constraint_default_parses() {
match parse_command("add constraint default 18 to Users.age").expect("parse") {
Command::AddConstraint { constraint, .. } => {
assert_eq!(
constraint,
Constraint::Default(Value::Number("18".to_string()))
);
}
other => panic!("expected AddConstraint, got {other:?}"),
}
}
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
Command::AddConstraint {
column, constraint, ..
} => {
assert_eq!(column, "age");
assert!(matches!(constraint, Constraint::Check(_)));
}
other => panic!("expected AddConstraint, got {other:?}"),
}
}
#[test]
fn drop_constraint_not_null_parses() {
match parse_command("drop constraint not null from Users.email").expect("parse") {
Command::DropConstraint {
table,
column,
kind,
} => {
assert_eq!(table, "Users");
assert_eq!(column, "email");
assert_eq!(kind, ConstraintKind::NotNull);
}
other => panic!("expected DropConstraint, got {other:?}"),
}
}
#[test]
fn drop_constraint_each_kind_parses() {
for (input, expected) in [
("drop constraint unique from T.c", ConstraintKind::Unique),
("drop constraint default from T.c", ConstraintKind::Default),
("drop constraint check from T.c", ConstraintKind::Check),
] {
match parse_command(input).expect("parse") {
Command::DropConstraint { kind, .. } => assert_eq!(kind, expected),
other => panic!("expected DropConstraint for {input:?}, got {other:?}"),
}
}
}
}