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:
+212
-7
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user