feat: ADR-0035 4g — ALTER TABLE add/drop constraint + add FK

ALTER TABLE <T> ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)
and DROP CONSTRAINT <name>. ADD = table-CHECK + composite UNIQUE + FK
(ADD PRIMARY KEY and a named UNIQUE refused — composite UNIQUE is
anonymous in our model). Each ADD reuses a low-level path with a dry-run
guard (table-CHECK/UNIQUE rebuild; FK -> add_relationship, bare
REFERENCES -> parent single PK). DROP CONSTRAINT resolves the name to a
named table-CHECK then a child-side FK, else refuses. One undo step each.

Named table-CHECKs round-trip: a nullable `name` column on
__rdbms_playground_table_checks (rebuild-only arrival; a named add on a
pre-4g project is refused with a "rebuild first" hint) plus a project.yaml
check_constraints {expr, name} extension (bare-string form still reads).
The internal-__rdbms_* guard was folded into do_add_constraint /
do_add_relationship, completing that guard class.

Grammar: the action Choice keeps one branch per verb (add/drop/rename/
alter) with an inner Choice fanning out on the distinct second keyword,
since the walker's Choice does not backtrack between same-led branches.

Tests: 7 Tier-1 parse + 2 yaml round-trip + 1 internal-guard + 9 Tier-3
e2e. Help/usage refreshed; ADR-0035 §13 4g + README + requirements.md in
lockstep.
This commit is contained in:
claude@clouddev1
2026-05-25 22:07:50 +00:00
parent 5b76315d1e
commit 6ff97f6e20
16 changed files with 1747 additions and 84 deletions
+103 -9
View File
@@ -23,7 +23,9 @@ use serde::Deserialize;
use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type;
use super::{ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableSchema};
use super::{
ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema,
};
/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
#[must_use]
@@ -113,11 +115,21 @@ fn write_table(out: &mut String, table: &TableSchema) {
}
// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) —
// double-quoted (an expression like `a < b` is not a bare scalar)
// and emitted only when present.
// and emitted only when present. An unnamed CHECK is a bare string
// (back-compatible); a named CHECK (ADR-0035 §4g) is an `{expr,
// name}` mapping so the name round-trips through a rebuild.
if !table.check_constraints.is_empty() {
let _ = writeln!(out, " check_constraints:");
for expr in &table.check_constraints {
let _ = writeln!(out, " - {}", yaml_string(expr));
for check in &table.check_constraints {
match &check.name {
None => {
let _ = writeln!(out, " - {}", yaml_string(&check.expr));
}
Some(name) => {
let _ = writeln!(out, " - expr: {}", yaml_string(&check.expr));
let _ = writeln!(out, " name: {}", yaml_string(name));
}
}
}
}
}
@@ -280,7 +292,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
primary_key: t.primary_key,
columns,
unique_constraints: t.unique_constraints,
check_constraints: t.check_constraints,
check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(),
});
}
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
@@ -394,10 +406,35 @@ struct RawTable {
/// Optional on read — older project files omit it.
#[serde(default)]
unique_constraints: Vec<Vec<String>>,
/// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3).
/// Optional on read — older project files omit it.
/// Table-level CHECK constraints (ADR-0035 §4a.3, named in §4g).
/// Optional on read — older project files omit it. Each entry is a
/// bare string (unnamed) or an `{expr, name}` mapping (named).
#[serde(default)]
check_constraints: Vec<String>,
check_constraints: Vec<RawTableCheck>,
}
/// A table-CHECK as read from `project.yaml`: a bare string (unnamed —
/// the pre-4g form, back-compatible) or an `{expr, name}` mapping (a
/// named CHECK, ADR-0035 §4g). `#[serde(untagged)]` tries the string
/// form first, then the mapping.
#[derive(Deserialize)]
#[serde(untagged)]
enum RawTableCheck {
Bare(String),
Named {
expr: String,
#[serde(default)]
name: Option<String>,
},
}
impl From<RawTableCheck> for TableCheck {
fn from(raw: RawTableCheck) -> Self {
match raw {
RawTableCheck::Bare(expr) => Self { name: None, expr },
RawTableCheck::Named { expr, name } => Self { name, expr },
}
}
}
#[derive(Deserialize)]
@@ -714,7 +751,10 @@ indexes:
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
],
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec!["a < b".to_string(), "b < c".to_string()],
check_constraints: vec![
TableCheck::unnamed("a < b"),
TableCheck::unnamed("b < c"),
],
}],
relationships: vec![],
indexes: vec![],
@@ -727,6 +767,60 @@ indexes:
);
}
#[test]
fn named_check_constraints_round_trip_through_yaml() {
// ADR-0035 §4g: a *named* table-CHECK serializes to the `{expr,
// name}` mapping form and round-trips, mixed with an unnamed one.
let snap = SchemaSnapshot {
created_at: "2026-05-25T00:00:00Z".to_string(),
tables: vec![TableSchema {
name: "T".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
],
unique_constraints: vec![],
check_constraints: vec![
TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() },
TableCheck::unnamed("qty < 1000"),
],
}],
relationships: vec![],
indexes: vec![],
};
let body = serialize_schema(&snap);
let parsed = parse_schema(&body).expect("parse schema");
assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip");
}
#[test]
fn old_format_bare_string_check_constraints_still_parse() {
// Back-compat: a project file written before §4g (bare-string
// check_constraints) parses with name = None.
let body = "\
version: 1
project:
created_at: \"2026-05-25T00:00:00Z\"
tables:
- name: T
primary_key: [id]
columns:
- { name: id, type: int }
- { name: qty, type: int }
check_constraints:
- \"qty >= 0\"
relationships: []
indexes: []
";
let parsed = parse_schema(body).expect("parse old-format schema");
assert_eq!(
parsed.tables[0].check_constraints,
vec![TableCheck::unnamed("qty >= 0")],
"a bare-string CHECK parses as an unnamed TableCheck"
);
}
#[test]
fn check_constraints_optional_on_read() {
// A project file written before table-level CHECK existed (no