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:
+31
-6
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user