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
+31 -6
View File
@@ -147,12 +147,37 @@ pub struct TableSchema {
/// optional on read.
pub unique_constraints: Vec<Vec<String>>,
/// Table-level `CHECK (<expr>)` constraints, in declaration
/// order, as raw SQL text (ADR-0035 §4a.3). The engine reports
/// no CHECK constraints, so these are the source of truth (held
/// in `__rdbms_playground_table_checks`) and echoed verbatim
/// into the rebuilt DDL. Empty for project files written before
/// table-level CHECK existed — the YAML field is optional on read.
pub check_constraints: Vec<String>,
/// order, as raw SQL text with an optional name (ADR-0035 §4a.3,
/// named in §4g). The engine reports no CHECK constraints, so these
/// are the source of truth (held in
/// `__rdbms_playground_table_checks`) and echoed verbatim into the
/// rebuilt DDL. Empty for project files written before table-level
/// CHECK existed — the YAML field is optional on read.
pub check_constraints: Vec<TableCheck>,
}
/// A table-level `CHECK` constraint with an optional name (ADR-0035 §4g).
///
/// The name is `Some` only for a `CONSTRAINT <name> CHECK (…)` added via
/// `ALTER TABLE` (the source of `DROP CONSTRAINT <name>`); a `CREATE
/// TABLE` table-CHECK and any pre-4g project file are unnamed (`None`).
/// The YAML carries a bare string for the unnamed form (back-compatible)
/// and an `{expr, name}` mapping for the named form.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableCheck {
pub name: Option<String>,
pub expr: String,
}
impl TableCheck {
/// An unnamed table-CHECK (the `CREATE TABLE` / pre-4g form).
#[must_use]
pub fn unnamed(expr: impl Into<String>) -> Self {
Self {
name: None,
expr: expr.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+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