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.
16 KiB
Plan: ADR-0035 Phase 4, sub-phase 4g — ALTER TABLE add/drop constraint + add FK
Add the advanced-mode SQL forms:
ALTER TABLE <T> ADD [CONSTRAINT <name>] CHECK (<expr>)— table-level CHECK, named or unnamed.ALTER TABLE <T> ADD UNIQUE (<col>, …)— composite UNIQUE (unnamed; see §2.4).ALTER TABLE <T> ADD [CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES <P>[(<col>)] [ON DELETE …] [ON UPDATE …]— a relationship.ALTER TABLE <T> DROP CONSTRAINT <name>— drop a named CHECK or a named FK (relationship).
Plus: fold the internal-__rdbms_* guard into do_add_constraint /
do_add_relationship (the remaining executors of the 4d/4e/4f guard
class).
User-confirmed scope (2026-05-25): full 4g in one slice; ADD =
CHECK + FK + composite UNIQUE (PRIMARY KEY refused); DROP CONSTRAINT =
named CHECK + named FK; named-CHECK round-trip via a project.yaml
format extension (the rebuild-only db-column arrival — pre-4g projects
gain the column on rebuild).
1. Baseline (at handoff)
- After 4f: 1865 passing, 0 failed, 0 skipped, 1 ignored; clippy
clean. Branch
main, HEAD5b76315(4f).
2. Decisions (settled — user-confirmed 2026-05-25)
- ADD scope = CHECK + FOREIGN KEY + composite UNIQUE.
ADD PRIMARY KEYis refused with a clear message (every playground table already has a PK; adding one is near-always invalid). PRIMARY KEY is not in the grammar's ADD-constraint surface. - CHECK migration = rebuild-only. The
CHECK_TABLE(__rdbms_playground_table_checks) CREATE schema gains a nullablenamecolumn; fresh + rebuilt databases get it. A pre-4g project on disk keeps the 3-column table untilrebuild. Unnamed table-CHECK creation continues to work on an old DB (its INSERT never names the column); a named CHECK add on an old DB is refused with a friendly "this project predates named constraints — runrebuildfirst" message (a single PRAGMA-guarded column-presence check, not an auto-migration). - Named-CHECK round-trip = project.yaml format extension. Because
rebuildreconstructs fromproject.yaml(do_rebuild_from_textparses the yaml, wipes the db, re-emits DDL viaschema_to_ddl), the CHECK name must live inproject.yaml, not just the db column.check_constraints: Vec<String>becomesVec<TableCheck>whereTableCheck { name: Option<String>, expr: String }. The yaml reader accepts both the old bare-string form (- "expr", name = None) and the new mapping form (- {expr: "…", name: "…"}), per the established "optional on read" backward-compat convention. - DROP CONSTRAINT scope = named CHECK + named FK. Resolution order:
look up
<name>inCHECK_TABLE(named table-CHECK) → drop it (rebuild without it + delete the row); else inREL_TABLE(named relationship) → drop it via the existing drop-relationship machinery; else refuse "no such constraint<name>on<T>". An unnamed / column-level / UNIQUE constraint is not a DROP CONSTRAINT target. - Composite UNIQUE is unnamed (asymmetry, intentional). A composite
UNIQUE constraint carries no user-facing name in our model
(PRAGMA-detected via the
origin='u'auto-index — ADR-0035 §4a.2), soADD UNIQUE (cols)creates an anonymous constraint and aCONSTRAINT <name>prefix on UNIQUE is refused ("naming a UNIQUE constraint is not supported — usealter table <T> add unique (cols)"). It therefore cannot be aDROP CONSTRAINT <name>target. This is consistent: ADD-UNIQUE is in scope, DROP-UNIQUE was never in scope (no name to target). - FK reuses the existing relationship machinery.
ADD [CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES <P>[(<col>)] …decomposes toadd_relationship(the same executoradd 1:n relationshipuses): parent-PK validation, bare-REFERENCES <P>→ parent single-PK resolution,fk_target_typecompatibility, auto-naming when unnamed, name-uniqueness, one undo step. No new FK executor. - CREATE TABLE table-CHECKs stay unnamed (out of 4g). Naming a CHECK
declared inside
CREATE TABLEis a separate consistency item; 4g only introduces names viaALTER … ADD CONSTRAINT <name> CHECK.do_create_tablewritesname = NULLfor its table-CHECKs. (Verify the CREATE grammar does not silently swallow aCONSTRAINT <name>on a table CHECK; if it parses one, it is currently dropped — leave as a noted follow-up, do not expand CREATE here.) - Internal-
__rdbms_*guard folded intodo_add_constraintanddo_add_relationship(both thetable/parent_tableand thechild_tablefor relationships). Closes the 4d/4e/4f guard class.
3. Phase 1 — Requirements checklist (4g)
Round-trip backbone (named table-CHECK)
CHECK_TABLECREATE schema gains nullablename TEXT(rebuild-only arrival).read_table_checksreads(name, check_expr)ordered byseq.ReadSchema.check_constraints: Vec<TableCheck>.schema_to_ddlemitsCONSTRAINT <name> CHECK (<expr>)when named, bareCHECK (<expr>)when not.persistence::TableSchema.check_constraints: Vec<TableCheck>; the db→persistence capture (db.rs:~2549) carries names.- yaml writes the mapping form for named, bare string for unnamed; yaml reads both old (bare string) and new (mapping) forms.
do_create_tablewritesname = NULLfor its table-CHECKs.- Round-trip test: a named table-CHECK survives save→load and
rebuild; an old-format yaml (bare strings) still loads.
Grammar / dispatch
AlterTableActiongainsAddTableConstraint { name: Option<String>, constraint: TableConstraint }andDropConstraint { name: String }.- New
TableConstraintenum:Check { expr_sql: String }(raw text —sql_expris validate-only),Unique { columns: Vec<String> },ForeignKey(Box<SqlForeignKey>)(reuse 4b struct). - Grammar:
AT_ADD_TABLE_CONSTRAINT(add [constraint <name>] (check (…) | unique (…) | foreign key (…) references …)) andAT_DROP_CONSTRAINT(drop constraint <name>), added toAT_ACTION_CHOICES. Reusesql_create_table's table-element nodes for CHECK / UNIQUE / FK where possible. - Builder discrimination order in
build_sql_alter_table:type→ (column⇒ add/rename/drop column) →add⇒ add-table-constraint →drop⇒ drop-constraint. (Checkingcolumnbefore the bareadd/dropkeepsadd column … unique/… checkrouting to AddColumn.) - Sub-discriminate the table-constraint by
check/unique/foreign. ACONSTRAINT <name>on UNIQUE refuses (§2.5). - Trailing
;tolerated; four existing AlterTableAction branches still route;alterstays advanced-only; table slot rejects__rdbms_*at parse.
Execution
- ADD CHECK (named/unnamed): dry-run guard (existing rows satisfy
the CHECK — reuse
dry_run_check), rebuild with the table-CHECK in the DDL, write theCHECK_TABLErow (table_name, seq=next, check_expr, name); auto-show; one undo step. Named add on an old DB (nonamecolumn) → friendly rebuild-needed refusal. - ADD UNIQUE (cols): dry-run guard (no duplicate tuples — reuse
dry_run_unique/the composite equivalent), rebuild adding the composite UNIQUE; one undo step. Survives rebuild (existingunique_constraintsyaml path). - ADD FOREIGN KEY: decompose to
add_relationship(name, parent, child, actions,create_fk = true?). Reuse 4b resolution (bareREFERENCES <P>, self-ref, type compat). One undo step. - DROP CONSTRAINT : resolve name in
CHECK_TABLEthenREL_TABLE; drop accordingly; refuse unknown. One undo step. - Internal-
__rdbms_*guard indo_add_constraint/do_add_relationship(both surfaces) + a test.
Testing
- Tier 1 (
sql_alter_table_testsin ddl.rs): parse each new form → the rightAlterTableAction; the six-branch dispatch still routes the four column actions; named-UNIQUE refusal;ADD PRIMARY KEYrefusal. - Tier 2/round-trip (persistence/yaml unit tests): named CHECK
serialize + parse; old bare-string parse;
Vec<TableCheck>save/load. - Tier 3 (
tests/sql_alter_table.rsviarun_replay): ADD named CHECK enforced + survives rebuild with its name; ADD UNIQUE enforced + survives rebuild; ADD FOREIGN KEY creates the relationship; DROP CONSTRAINT removes a named CHECK and a named FK; DROP unknown refused; one undo step each. - Internal guard (
tests/column_op_guards.rs): simpleadd_constraint/add_relationshipon__rdbms_*refused. - Catalog lockstep + vocab audit for the refreshed
sql_alter_tablehelp/usage (now listing add/drop constraint + add FK).
4. Architecture & change list (file by file)
src/persistence/mod.rs: addpub struct TableCheck { pub name: Option<String>, pub expr: String };TableSchema.check_constraints: Vec<TableCheck>. Update thecsv_io.rs/mod.rsconstructors that setcheck_constraints: Vec::new()(type still compiles — empty Vec).src/persistence/yaml.rs: serialize aTableCheckas a bare string whennameis None, else a{expr, name}mapping; parse both forms (back-compat). Update the parser struct + the round-trip tests.src/db.rs:CHECK_TABLECREATE schema +=name TEXT(nullable).read_table_checks→Vec<TableCheck>(read name; tolerate a missingnamecolumn on an old DB via a column-presence check → name = None).ReadSchema.check_constraints: Vec<TableCheck>;schema_to_ddlemitsCONSTRAINT <name>when named.- db→persistence capture (≈2549) maps
Vec<TableCheck>. build_read_schema(yaml variant, ≈8125) maps persistenceTableCheck→ReadSchemaTableCheck.do_create_tabletable-CHECK INSERT writesname = NULL.- New executors:
do_alter_add_table_check,do_alter_add_unique,do_drop_constraint_by_name. Worker methodsRequestvariants + handler dispatch (wrapped insnapshot_then).
reject_internal_table_nameat the top ofdo_add_constraint/do_add_relationship(+ child_table for the latter).- A
check_table_has_name_column(conn)helper for the rebuild-needed refusal.
src/dsl/command.rs:AlterTableAction::{AddTableConstraint, DropConstraint};TableConstraintenum.src/dsl/grammar/ddl.rs:AT_ADD_TABLE_CONSTRAINT,AT_DROP_CONSTRAINT, builder branches + sub-discrimination, the named-UNIQUE / ADD-PRIMARY-KEY refusals.src/dsl/grammar/sql_create_table.rs: expose the table-CHECK / UNIQUE / FK element nodes for reuse if not alreadypub(crate).src/runtime.rs:SqlAlterTablearm → the new executors;AddTableConstraint::ForeignKey→add_relationship.src/app.rs:build_translate_contextarms for the two new actions (Operation::AddConstraint / DropConstraint / AddRelationship).src/friendly/{keys.rs,strings/en-US.yaml}: refreshsql_alter_tablehelp/usage; any new refusal message keys.
5. Phase 2 — Candidate approaches (key forks)
Round-trip representation. (R1) Vec<TableCheck{name,expr}> threaded
through ReadSchema + persistence (lead — single source of truth, clean
rebuild). (R2) parallel Vec<Option<String>> names alongside the
existing Vec<String> — rejected (two vectors to keep aligned, error
prone). (R3) store names only in the db, not yaml — rejected (names
lost on rebuild; breaks DROP CONSTRAINT after rebuild).
Executor structure. (E1) one do_alter_add_table_check + one
do_alter_add_unique + FK via add_relationship + one
do_drop_constraint_by_name (lead — each maps to one rebuild, mirrors
the 4e/4f decomposition). (E2) a single mega-executor switching on a
constraint enum — rejected (a fat function; the three adds have
genuinely different dry-run guards + metadata writes).
Grammar. (G1) separate Choice branches AT_ADD_TABLE_CONSTRAINT /
AT_DROP_CONSTRAINT added to AT_ACTION_CHOICES, builder discriminates
by column then add/drop then the constraint keyword (lead —
consistent with the existing five branches; reuses the create-table
element nodes). (G2) a nested sub-Choice under a single add branch —
rejected (complicates the builder more than separate branches).
6. Phase 3 — Selection
R1 + E1 + G1. Satisfies every §3 item with the smallest faithful change: the round-trip backbone is a typed extension (not a parallel array), the executors each reduce to one rebuild + one metadata write (one undo step), and the grammar mirrors the established branch structure. The named-UNIQUE refusal and ADD-PRIMARY-KEY refusal keep the surface honest about what the model can persist.
7. Devil's Advocate review of this plan
- Forks escalated? ADD scope, the migration approach, and DROP scope
were put to the user (2026-05-25) and answered (CHECK+FK+UNIQUE /
rebuild-only / CHECK+FK). The newly-discovered yaml-format-change
implication was surfaced and the user chose "Full 4g now". The
named-UNIQUE-refusal and CREATE-CHECK-stays-unnamed micro-decisions are
consequences of the model (anonymous composite UNIQUE; ALTER-only
naming) — noted here, to be confirmed in the combined
/runda. ✓ - Back-compat of the yaml change? The reader accepts both the bare string and the mapping form; a test covers an old-format file. The field stays "optional on read". ✓
- Old-DB named-CHECK add? Guarded by a column-presence check → a friendly engine-neutral rebuild-needed refusal, not a raw engine error (ADR-0035 §9). Unnamed CHECK adds keep working on an old DB. ✓
- One undo step each? Each add/drop is one executor call = one rebuild = one snapshot, like 4e/4f. e2e undo checks. ✓
- Grammar trap? Six concrete-keyword-led branches; the builder keys
on
column(column ops) thenadd/dropthen the constraint keyword.add column … unique/checkstill routes to AddColumn (checked viacolumnfirst). A parse test for every branch + the discrimination edges. ✓ - Engine neutrality? New refusal messages say "the database" / "constraint" in the abstract; vocab audit + catalog lockstep tests guard it. ✓
- Anything dropped? ADD PRIMARY KEY (refused, stated), named UNIQUE (refused, stated), CREATE-TABLE CHECK naming (out of scope, noted). No silent drops.
8. Implementation sequence (test-first)
- Internal guards —
reject_internal_table_nameindo_add_constraint/do_add_relationship;column_op_guards.rstests (red → green). Isolated, lands first. - Round-trip backbone —
TableChecktype;CHECK_TABLE+name;read_table_checks/ReadSchema/schema_to_ddl/ capture /do_create_table/ yaml serialize+parse. Persistence/yaml round-trip tests (incl. old-format read). No behaviour change yet (all CHECKs still unnamed until the grammar lands) → full suite stays green. - Command + grammar + builder — the two actions +
TableConstraint;AT_ADD_TABLE_CONSTRAINT/AT_DROP_CONSTRAINT; the discrimination + refusals; Tier-1 parse tests → exhaustive arms (compiler) → green (parse only). - Executors + runtime + catalog —
do_alter_add_table_check,do_alter_add_unique,do_drop_constraint_by_name, FK viaadd_relationship; wireSqlAlterTable; refresh help/usage; Tier-3 e2e (ADD CHECK/UNIQUE/FK, DROP CHECK/FK, refusals, rebuild survival, undo) → green. - Full sweep —
cargo test(no regression from 1865) +cargo clippy --all-targets -- -D warnings. - Docs — ADR-0035 Status + §13 4g; README; requirements Q1. Defer
the formal
/rundato the combined pass (user steer). Propose commit; wait for approval.
9. Exit gate
- All §3 items satisfied; all tiers green, zero skips; no regression from
1865; written-DA PASS (combined
/rundato follow); clippy clean; ADR-0035 §13 4g + README + requirements.md lockstep.