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
@@ -4,14 +4,15 @@
Accepted. Design agreed with the user (2026-05-24); the approach is Accepted. Design agreed with the user (2026-05-24); the approach is
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d / **validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d /
4e / 4f** (`CREATE TABLE` with column- and table-level constraints and 4e / 4f / 4g** (`CREATE TABLE` with column- and table-level constraints
foreign keys, `DROP TABLE [IF EXISTS]`, `CREATE [UNIQUE] INDEX` / and foreign keys, `DROP TABLE [IF EXISTS]`, `CREATE [UNIQUE] INDEX` /
`DROP INDEX [IF EXISTS]`, `ALTER TABLE` add/drop/rename column, and `DROP INDEX [IF EXISTS]`, `ALTER TABLE` add/drop/rename column,
`ALTER TABLE … ALTER COLUMN TYPE`, implemented 2026-05-25 — plans `ALTER TABLE … ALTER COLUMN TYPE`, and `ALTER TABLE` add/drop constraint
+ add foreign key, implemented 2026-05-25 — plans
`docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`, `docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`,
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`, `docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`,
`…-4e.md`, `…-4f.md`), so the decision is accepted while the remaining `…-4e.md`, `…-4f.md`, `…-4g.md`), so the decision is accepted while the
sub-phases (**4g4i**, §13) continue. This is **Phase 4** of the ADR-0030 roadmap (the remaining sub-phases (**4h4i**, §13) continue. This is **Phase 4** of the ADR-0030 roadmap (the
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar), advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4** ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
on how DDL is represented and executed. on how DDL is represented and executed.
@@ -456,6 +457,30 @@ ADR-0033's structure:
exposure too. *(The remaining internal-table guard on exposure too. *(The remaining internal-table guard on
`do_add_constraint` / `do_add_relationship` rides in 4g.)* `do_add_constraint` / `do_add_relationship` rides in 4g.)*
- **4g — `ALTER TABLE` add/drop constraint, add foreign key.** - **4g — `ALTER TABLE` add/drop constraint, add foreign key.**
*(Implemented 2026-05-25 — plan
`docs/plans/20260525-adr-0035-sql-ddl-4g.md`.)* `ALTER TABLE <T> ADD
[CONSTRAINT <name>] (CHECK (…) | UNIQUE (…) | FOREIGN KEY (…)
REFERENCES …)` and `DROP CONSTRAINT <name>`. **ADD scope (user-
confirmed):** CHECK + composite UNIQUE + FK; `ADD PRIMARY KEY` is
refused (every table already has a PK) and a **named UNIQUE** is
refused (composite UNIQUE is anonymous in our model — PRAGMA-detected,
§4a.2). Each ADD reuses an existing low-level path: table-CHECK and
composite-UNIQUE rebuild the table (dry-run guards reject existing
rows that would violate), FK decomposes to `add_relationship` (the
same machinery `add 1:n relationship` uses — bare `REFERENCES <P>`
resolves to the parent's single PK; `create_fk = false` as the column
must exist). **DROP CONSTRAINT (user-confirmed)** resolves the name to
a named table-CHECK then a named FK whose child is `<T>`, else refuses.
**Named table-CHECK round-trip (user-confirmed):** the `CHECK_TABLE`
metadata gains a nullable `name` column (**rebuild-only** arrival — a
pre-4g project gains it on `rebuild`; a named CHECK add on an
un-upgraded project is refused with a friendly "rebuild first"
message), and `project.yaml`'s `check_constraints` is **extended** to
carry the name (`{expr, name}` mapping; the bare-string form still
reads, name = `None`) so a named CHECK survives a rebuild — `rebuild`
reconstructs from the yaml. The internal-`__rdbms_*` guard was folded
into `do_add_constraint` / `do_add_relationship`, completing the
4d/4e/4f guard class. One undo step per statement.
- **4h — `ALTER TABLE … RENAME TO`** (the §6 new low-level op). - **4h — `ALTER TABLE … RENAME TO`** (the §6 new low-level op).
- **4i — Verification sweep.** Typing-surface + matrix coverage, - **4i — Verification sweep.** Typing-surface + matrix coverage,
engine-neutral error pass, undo-parity check (one step per engine-neutral error pass, undo-parity check (one step per
+1 -1
View File
File diff suppressed because one or more lines are too long
+287
View File
@@ -0,0 +1,287 @@
# 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`, HEAD `5b76315` (4f).
## 2. Decisions (settled — user-confirmed 2026-05-25)
1. **ADD scope = CHECK + FOREIGN KEY + composite UNIQUE.** `ADD PRIMARY
KEY` is **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.
2. **CHECK migration = rebuild-only.** The `CHECK_TABLE`
(`__rdbms_playground_table_checks`) CREATE schema gains a nullable
`name` column; fresh + rebuilt databases get it. A pre-4g project on
disk keeps the 3-column table until `rebuild`. 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 — run `rebuild`
first" message (a single PRAGMA-guarded column-presence check, **not**
an auto-migration).
3. **Named-CHECK round-trip = project.yaml format extension.** Because
`rebuild` reconstructs from `project.yaml` (`do_rebuild_from_text`
parses the yaml, wipes the db, re-emits DDL via `schema_to_ddl`), the
CHECK *name* must live in `project.yaml`, not just the db column.
`check_constraints: Vec<String>` becomes `Vec<TableCheck>` where
`TableCheck { 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.
4. **DROP CONSTRAINT scope = named CHECK + named FK.** Resolution order:
look up `<name>` in `CHECK_TABLE` (named table-CHECK) → drop it
(rebuild without it + delete the row); else in `REL_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.
5. **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), so
`ADD UNIQUE (cols)` creates an anonymous constraint and **a
`CONSTRAINT <name>` prefix on UNIQUE is refused** ("naming a UNIQUE
constraint is not supported — use `alter table <T> add unique
(cols)`"). It therefore cannot be a `DROP CONSTRAINT <name>` target.
This is consistent: ADD-UNIQUE is in scope, DROP-UNIQUE was never in
scope (no name to target).
6. **FK reuses the existing relationship machinery.** `ADD [CONSTRAINT
<name>] FOREIGN KEY (<col>) REFERENCES <P>[(<col>)] …` decomposes to
`add_relationship` (the same executor `add 1:n relationship` uses):
parent-PK validation, bare-`REFERENCES <P>` → parent single-PK
resolution, `fk_target_type` compatibility, auto-naming when unnamed,
name-uniqueness, one undo step. No new FK executor.
7. **CREATE TABLE table-CHECKs stay unnamed (out of 4g).** Naming a CHECK
declared *inside* `CREATE TABLE` is a separate consistency item; 4g
only introduces names via `ALTER … ADD CONSTRAINT <name> CHECK`.
`do_create_table` writes `name = NULL` for its table-CHECKs. (Verify
the CREATE grammar does not silently swallow a `CONSTRAINT <name>` on
a table CHECK; if it parses one, it is currently dropped — leave as a
noted follow-up, do not expand CREATE here.)
8. **Internal-`__rdbms_*` guard** folded into `do_add_constraint` and
`do_add_relationship` (both the `table`/`parent_table` and the
`child_table` for relationships). Closes the 4d/4e/4f guard class.
## 3. Phase 1 — Requirements checklist (4g)
### Round-trip backbone (named table-CHECK)
- [ ] `CHECK_TABLE` CREATE schema gains nullable `name TEXT` (rebuild-only
arrival). `read_table_checks` reads `(name, check_expr)` ordered by
`seq`. `ReadSchema.check_constraints: Vec<TableCheck>`.
- [ ] `schema_to_ddl` emits `CONSTRAINT <name> CHECK (<expr>)` when named,
bare `CHECK (<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_table` writes `name = NULL` for 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
- [ ] `AlterTableAction` gains `AddTableConstraint { name: Option<String>,
constraint: TableConstraint }` and `DropConstraint { name: String }`.
- [ ] New `TableConstraint` enum: `Check { expr_sql: String }` (raw text
— `sql_expr` is 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 …)`) and
`AT_DROP_CONSTRAINT` (`drop constraint <name>`), added to
`AT_ACTION_CHOICES`. Reuse `sql_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. (Checking `column`
before the bare `add`/`drop` keeps `add column … unique`/`… check`
routing to AddColumn.)
- [ ] Sub-discriminate the table-constraint by `check` / `unique` /
`foreign`. A `CONSTRAINT <name>` on UNIQUE refuses (§2.5).
- [ ] Trailing `;` tolerated; four existing AlterTableAction branches
still route; `alter` stays 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 the `CHECK_TABLE` row (`table_name, seq=next, check_expr,
name`); auto-show; one undo step. Named add on an old DB (no `name`
column) → 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 (existing
`unique_constraints` yaml path).
- [ ] **ADD FOREIGN KEY**: decompose to `add_relationship` (name,
parent, child, actions, `create_fk = true`?). Reuse 4b resolution
(bare `REFERENCES <P>`, self-ref, type compat). One undo step.
- [ ] **DROP CONSTRAINT <name>**: resolve name in `CHECK_TABLE` then
`REL_TABLE`; drop accordingly; refuse unknown. One undo step.
- [ ] Internal-`__rdbms_*` guard in `do_add_constraint` /
`do_add_relationship` (both surfaces) + a test.
### Testing
- [ ] **Tier 1** (`sql_alter_table_tests` in ddl.rs): parse each new form
→ the right `AlterTableAction`; the six-branch dispatch still routes
the four column actions; named-UNIQUE refusal; `ADD PRIMARY KEY`
refusal.
- [ ] **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.rs` via `run_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`): simple
`add_constraint` / `add_relationship` on `__rdbms_*` refused.
- [ ] **Catalog** lockstep + vocab audit for the refreshed
`sql_alter_table` help/usage (now listing add/drop constraint + add FK).
## 4. Architecture & change list (file by file)
- **`src/persistence/mod.rs`**: add `pub struct TableCheck { pub name:
Option<String>, pub expr: String }`; `TableSchema.check_constraints:
Vec<TableCheck>`. Update the `csv_io.rs` / `mod.rs` constructors that
set `check_constraints: Vec::new()` (type still compiles — empty Vec).
- **`src/persistence/yaml.rs`**: serialize a `TableCheck` as a bare
string when `name` is None, else a `{expr, name}` mapping; parse both
forms (back-compat). Update the parser struct + the round-trip tests.
- **`src/db.rs`**:
- `CHECK_TABLE` CREATE schema += `name TEXT` (nullable).
- `read_table_checks` → `Vec<TableCheck>` (read name; tolerate a
missing `name` column on an old DB via a column-presence check →
name = None).
- `ReadSchema.check_constraints: Vec<TableCheck>`; `schema_to_ddl`
emits `CONSTRAINT <name>` when named.
- db→persistence capture (≈2549) maps `Vec<TableCheck>`.
- `build_read_schema` (yaml variant, ≈8125) maps persistence
`TableCheck` → `ReadSchema` `TableCheck`.
- `do_create_table` table-CHECK INSERT writes `name = NULL`.
- New executors: `do_alter_add_table_check`,
`do_alter_add_unique`, `do_drop_constraint_by_name`. Worker methods
+ `Request` variants + handler dispatch (wrapped in `snapshot_then`).
- `reject_internal_table_name` at the top of `do_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}`; `TableConstraint` enum.
- **`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 already `pub(crate)`.
- **`src/runtime.rs`**: `SqlAlterTable` arm → the new executors;
`AddTableConstraint::ForeignKey` → `add_relationship`.
- **`src/app.rs`**: `build_translate_context` arms for the two new
actions (Operation::AddConstraint / DropConstraint / AddRelationship).
- **`src/friendly/{keys.rs,strings/en-US.yaml}`**: refresh
`sql_alter_table` help/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) then `add`/`drop` then the constraint keyword.
`add column … unique/check` still routes to AddColumn (checked via
`column` first). 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)
1. **Internal guards** — `reject_internal_table_name` in
`do_add_constraint` / `do_add_relationship`; `column_op_guards.rs`
tests (red → green). Isolated, lands first.
2. **Round-trip backbone** — `TableCheck` type; `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.
3. **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).
4. **Executors + runtime + catalog** — `do_alter_add_table_check`,
`do_alter_add_unique`, `do_drop_constraint_by_name`, FK via
`add_relationship`; wire `SqlAlterTable`; refresh help/usage; Tier-3
e2e (ADD CHECK/UNIQUE/FK, DROP CHECK/FK, refusals, rebuild survival,
undo) → green.
5. **Full sweep** — `cargo test` (no regression from 1865) + `cargo
clippy --all-targets -- -D warnings`.
6. **Docs** — ADR-0035 Status + §13 4g; README; requirements Q1. Defer
the formal `/runda` to 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 `/runda` to follow); clippy clean;
ADR-0035 §13 4g + README + requirements.md lockstep.
+11 -3
View File
@@ -238,9 +238,17 @@ handoff-14 cleanup; 449 after B2/C2.)
runtime-decomposed to `change_column_type` with `ForceConversion`, the runtime-decomposed to `change_column_type` with `ForceConversion`, the
§7 advanced policy: lossy converts with a note, incompatible + static §7 advanced policy: lossy converts with a note, incompatible + static
refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed; refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed;
the internal-`__rdbms_*` guard folded into `do_change_column_type`)). the internal-`__rdbms_*` guard folded into `do_change_column_type`),
Remaining DDL — `ALTER TABLE` add-drop-constraint / add-FK / `RENAME TO` then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
(4g4h) — is phased per ADR-0035 §13.)* <name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
ADD = CHECK + composite UNIQUE + FK (PRIMARY KEY + named UNIQUE
refused); table-CHECK/UNIQUE rebuild with a dry-run guard, FK reuses
`add_relationship`; named table-CHECKs round-trip via a rebuild-only
`name` column on `__rdbms_playground_table_checks` + a `project.yaml`
`check_constraints` `{expr, name}` extension; the internal-table guard
completed across `do_add_constraint`/`do_add_relationship`)).
Remaining DDL — `ALTER TABLE … RENAME TO` (4h) — is phased per
ADR-0035 §13.)*
- [ ] **Q2** Non-standard syntax rejected with a clear message - [ ] **Q2** Non-standard syntax rejected with a clear message
pointing at the supported subset. pointing at the supported subset.
*(Design done — ADR-0030 §8: out-of-subset statements are *(Design done — ADR-0030 §8: out-of-subset statements are
+6
View File
@@ -1602,6 +1602,12 @@ impl App {
Some(table.as_str()), Some(table.as_str()),
Some(column.as_str()), Some(column.as_str()),
), ),
AlterTableAction::AddTableConstraint { .. } => {
(Operation::AddConstraint, Some(table.as_str()), None)
}
AlterTableAction::DropConstraint { .. } => {
(Operation::DropConstraint, Some(table.as_str()), None)
}
}, },
C::SqlCreateTable { name, .. } => { C::SqlCreateTable { name, .. } => {
(Operation::CreateTable, Some(name.as_str()), None) (Operation::CreateTable, Some(name.as_str()), None)
+544 -21
View File
@@ -43,7 +43,7 @@ use crate::output_render::{Alignment, render_diagnostic_table};
use crate::type_change; use crate::type_change;
use crate::persistence::{ use crate::persistence::{
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema, CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
}; };
use crate::project::{DATA_DIR, PROJECT_YAML}; use crate::project::{DATA_DIR, PROJECT_YAML};
use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged}; use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged};
@@ -594,6 +594,40 @@ enum Request {
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>, reply: oneshot::Sender<Result<TableDescription, DbError>>,
}, },
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
/// table-level CHECK, named or unnamed (ADR-0035 §4g).
AlterAddTableCheck {
table: String,
name: Option<String>,
expr_sql: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
/// constraint (ADR-0035 §4g).
AlterAddUnique {
table: String,
columns: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
/// CHECK or a named FK (ADR-0035 §4g).
AlterDropConstraint {
table: String,
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
},
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
/// REFERENCES …` — a relationship on an existing table (ADR-0035 §4g).
AlterAddForeignKey {
child_table: String,
name: Option<String>,
fk: Box<SqlForeignKey>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
Insert { Insert {
table: String, table: String,
columns: Option<Vec<String>>, columns: Option<Vec<String>>,
@@ -1075,6 +1109,87 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
/// table-level CHECK (ADR-0035 §4g).
pub async fn alter_add_table_check(
&self,
table: String,
name: Option<String>,
expr_sql: String,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddTableCheck {
table,
name,
expr_sql,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
/// constraint (ADR-0035 §4g).
pub async fn alter_add_unique(
&self,
table: String,
columns: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddUnique {
table,
columns,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
/// CHECK or a named FK (ADR-0035 §4g).
pub async fn alter_drop_constraint(
&self,
table: String,
name: String,
source: Option<String>,
) -> Result<Option<TableDescription>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterDropConstraint {
table,
name,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
/// REFERENCES …` — add a relationship to an existing table (ADR-0035
/// §4g).
pub async fn alter_add_foreign_key(
&self,
child_table: String,
name: Option<String>,
fk: SqlForeignKey,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddForeignKey {
child_table,
name,
fk: Box::new(fk),
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn rename_column( pub async fn rename_column(
&self, &self,
table: String, table: String,
@@ -1547,6 +1662,7 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
table_name TEXT NOT NULL,\n\ table_name TEXT NOT NULL,\n\
seq INTEGER NOT NULL,\n\ seq INTEGER NOT NULL,\n\
check_expr TEXT NOT NULL,\n\ check_expr TEXT NOT NULL,\n\
name TEXT,\n\
PRIMARY KEY (table_name, seq)\n\ PRIMARY KEY (table_name, seq)\n\
) STRICT;\n\ ) STRICT;\n\
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\ CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
@@ -2139,6 +2255,62 @@ fn handle_request(
kind, kind,
)); ));
} }
Request::AlterAddTableCheck {
table,
name,
expr_sql,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_table_check(
conn,
persistence,
source.as_deref(),
&table,
name.as_deref(),
&expr_sql,
)
});
}
Request::AlterAddUnique {
table,
columns,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns)
});
}
Request::AlterDropConstraint {
table,
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name)
});
}
Request::AlterAddForeignKey {
child_table,
name,
fk,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_foreign_key(
conn,
persistence,
source.as_deref(),
&child_table,
name.as_deref(),
&fk,
)
});
}
Request::Insert { Request::Insert {
table, table,
columns, columns,
@@ -3468,6 +3640,11 @@ fn do_add_constraint(
column: &str, column: &str,
constraint: &Constraint, constraint: &Constraint,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
// Refuse the internal `__rdbms_*` tables up-front (as "no such
// table"), like the sibling schema-mutation executors. Closes the
// simple `add constraint` exposure and the SQL `ALTER TABLE … ADD
// CONSTRAINT` decomposition target (ADR-0035 §4g).
reject_internal_table_name(table)?;
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
let (col_is_pk, col_user_type) = { let (col_is_pk, col_user_type) = {
let col = old_schema let col = old_schema
@@ -5107,11 +5284,13 @@ struct ReadSchema {
/// read from the UNIQUE-constraint indexes (`origin = 'u'`). /// read from the UNIQUE-constraint indexes (`origin = 'u'`).
/// Single-column UNIQUE rides on `ReadColumn::unique` instead. /// Single-column UNIQUE rides on `ReadColumn::unique` instead.
unique_constraints: Vec<Vec<String>>, unique_constraints: Vec<Vec<String>>,
/// Table-level CHECK constraints as raw SQL text, in declaration /// Table-level CHECK constraints as raw SQL text with an optional
/// order (ADR-0035 §4a.3). The engine reports no CHECK constraints, /// name, in declaration order (ADR-0035 §4a.3, named in §4g). The
/// so these are read from `__rdbms_playground_table_checks` rather /// engine reports no CHECK constraints, so these are read from
/// than PRAGMA, and echoed verbatim by `schema_to_ddl` on rebuild. /// `__rdbms_playground_table_checks` rather than PRAGMA, and echoed
check_constraints: Vec<String>, /// verbatim by `schema_to_ddl` on rebuild (`CONSTRAINT <name>` when
/// named).
check_constraints: Vec<TableCheck>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -5245,19 +5424,32 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
}) })
} }
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3) from /// Read a table's table-level CHECK constraints (ADR-0035 §4a.3, named
/// `CHECK_TABLE`, in declaration order (`seq`). The engine exposes no /// in §4g) from `CHECK_TABLE`, in declaration order (`seq`). The engine
/// PRAGMA for CHECK constraints, so this metadata table is their only /// exposes no PRAGMA for CHECK constraints, so this metadata table is
/// source of truth. /// their only source of truth. Tolerates a pre-4g project whose table
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbError> { /// predates the `name` column (rebuild-only migration) by reading the
let mut stmt = conn /// name as `None`.
.prepare(&format!( fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<TableCheck>, DbError> {
let has_name = check_table_has_name_column(conn)?;
let sql = if has_name {
format!(
"SELECT check_expr, name FROM {CHECK_TABLE} \
WHERE table_name = ?1 ORDER BY seq;"
)
} else {
format!(
"SELECT check_expr FROM {CHECK_TABLE} \ "SELECT check_expr FROM {CHECK_TABLE} \
WHERE table_name = ?1 ORDER BY seq;" WHERE table_name = ?1 ORDER BY seq;"
)) )
.map_err(DbError::from_rusqlite)?; };
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let rows = stmt let rows = stmt
.query_map([table], |row| row.get::<_, String>(0)) .query_map([table], |row| {
let expr: String = row.get(0)?;
let name: Option<String> = if has_name { row.get(1)? } else { None };
Ok(TableCheck { name, expr })
})
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows { for row in rows {
@@ -5266,6 +5458,23 @@ fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbEr
Ok(out) Ok(out)
} }
/// Whether `CHECK_TABLE` carries the `name` column (ADR-0035 §4g). A
/// pre-4g project's metadata table predates it — the column arrives on
/// `rebuild` (the rebuild-only migration, user-confirmed 2026-05-25).
/// Used to read names tolerantly and to refuse a *named* CHECK add on an
/// un-upgraded project with a friendly "rebuild first" message rather
/// than a raw engine error.
fn check_table_has_name_column(conn: &Connection) -> Result<bool, DbError> {
let count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
Ok(count > 0)
}
/// Whether the raw CHECK expression `check_expr` references the column /// Whether the raw CHECK expression `check_expr` references the column
/// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard). /// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard).
/// ///
@@ -5349,8 +5558,8 @@ fn column_referenced_by_check(
column: &str, column: &str,
include_self: bool, include_self: bool,
) -> Result<bool, DbError> { ) -> Result<bool, DbError> {
for expr in read_table_checks(conn, table)? { for check in read_table_checks(conn, table)? {
if check_references_column(&expr, column) { if check_references_column(&check.expr, column) {
return Ok(true); return Ok(true);
} }
} }
@@ -5561,9 +5770,18 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
// Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim // Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
// from the raw SQL stored in the metadata table, emitted identically // from the raw SQL stored in the metadata table, emitted identically
// to `do_create_table` (the §6.1 two-generators rule). // to `do_create_table` (the §6.1 two-generators rule). A named CHECK
for expr in &schema.check_constraints { // (ADR-0035 §4g) re-emits its `CONSTRAINT <name>` prefix so the name
clauses.push(format!("CHECK ({expr})")); // round-trips through a rebuild.
for check in &schema.check_constraints {
match &check.name {
Some(name) => clauses.push(format!(
"CONSTRAINT {ident} CHECK ({expr})",
ident = quote_ident(name),
expr = check.expr,
)),
None => clauses.push(format!("CHECK ({expr})", expr = check.expr)),
}
} }
for fk in &schema.foreign_keys { for fk in &schema.foreign_keys {
@@ -5981,6 +6199,12 @@ fn do_add_relationship(
on_update: ReferentialAction, on_update: ReferentialAction,
create_fk: bool, create_fk: bool,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
// Refuse the internal `__rdbms_*` tables on either endpoint (as "no
// such table"), like the sibling schema-mutation executors. Closes
// the simple `add 1:n relationship` exposure and the SQL `ALTER
// TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g).
reject_internal_table_name(parent_table)?;
reject_internal_table_name(child_table)?;
// 1. Read parent schema; verify the referenced column is a PK. // 1. Read parent schema; verify the referenced column is a PK.
let parent_schema = read_schema(conn, parent_table)?; let parent_schema = read_schema(conn, parent_table)?;
let parent_col = parent_schema let parent_col = parent_schema
@@ -6180,6 +6404,305 @@ fn do_drop_relationship(
Ok(Some(do_describe_table(conn, &parent_table)?)) Ok(Some(do_describe_table(conn, &parent_table)?))
} }
/// `ALTER TABLE <T> ADD [CONSTRAINT <name>] CHECK (<expr>)` (ADR-0035
/// §4g). A dry-run refuses the add if any existing row fails the
/// predicate; the rebuild then re-emits the table with the new CHECK in
/// its DDL and records it in `CHECK_TABLE`. A *named* CHECK on a pre-4g
/// project (whose metadata table predates the `name` column — the
/// rebuild-only migration) is refused with a friendly "rebuild first"
/// message rather than a raw engine error.
fn do_alter_add_table_check(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
name: Option<&str>,
expr_sql: &str,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(table)?;
let old_schema = read_schema(conn, table)?;
if name.is_some() && !check_table_has_name_column(conn)? {
return Err(DbError::Unsupported(
"this project predates named constraints; run `rebuild` to \
upgrade it, then add the named constraint again."
.to_string(),
));
}
// A named CHECK must not collide with an existing CHECK name on this
// table NOR with a relationship name (FKs are also `DROP CONSTRAINT`
// targets) — keeps `drop constraint <name>` unambiguous.
if let Some(n) = name {
let collides_check = old_schema
.check_constraints
.iter()
.any(|c| c.name.as_deref() == Some(n));
let collides_rel: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
[n],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if collides_check || collides_rel > 0 {
return Err(DbError::Unsupported(format!(
"a constraint named `{n}` already exists on `{table}`."
)));
}
}
// Dry-run: a CHECK passes on TRUE or NULL; only FALSE fails, so
// `WHERE NOT (expr)` counts the genuine violations.
let violating: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {tbl} WHERE NOT ({expr_sql});",
tbl = quote_ident(table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if violating > 0 {
return Err(DbError::Unsupported(format!(
"cannot add CHECK ({expr_sql}) to `{table}`: {violating} existing \
row(s) do not satisfy it."
)));
}
let mut new_schema = old_schema.clone();
new_schema.check_constraints.push(TableCheck {
name: name.map(ToString::to_string),
expr: expr_sql.to_string(),
});
let table_owned = table.to_string();
let name_owned = name.map(ToString::to_string);
let expr_owned = expr_sql.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
// MAX(seq)+1 avoids colliding with a gap a prior DROP left.
let next_seq: i64 = tx
.query_row(
&format!(
"SELECT COALESCE(MAX(seq), -1) + 1 FROM {CHECK_TABLE} \
WHERE table_name = ?1;"
),
[table_owned.as_str()],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
VALUES (?1, ?2, ?3, ?4);"
),
rusqlite::params![table_owned, next_seq, expr_owned, name_owned],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
do_describe_table(conn, table)
}
/// `ALTER TABLE <T> ADD UNIQUE (<col>, …)` (ADR-0035 §4g) — a composite
/// UNIQUE constraint (anonymous: composite UNIQUE is PRAGMA-detected on
/// read, ADR-0035 §4a.2, so it carries no name). A dry-run refuses the
/// add if existing rows already contain a duplicate non-NULL tuple
/// (NULLs are distinct under SQL's UNIQUE semantics).
fn do_alter_add_unique(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
columns: &[String],
) -> Result<TableDescription, DbError> {
reject_internal_table_name(table)?;
let old_schema = read_schema(conn, table)?;
for c in columns {
if !old_schema.columns.iter().any(|oc| &oc.name == c) {
return Err(DbError::Sqlite {
message: format!("no such column: {table}.{c}"),
kind: SqliteErrorKind::NoSuchColumn,
});
}
}
let non_null = columns
.iter()
.map(|c| format!("{} IS NOT NULL", quote_ident(c)))
.collect::<Vec<_>>()
.join(" AND ");
let group = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let dup_groups: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM (SELECT 1 FROM {tbl} WHERE {non_null} \
GROUP BY {group} HAVING COUNT(*) > 1);",
tbl = quote_ident(table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if dup_groups > 0 {
return Err(DbError::Unsupported(format!(
"cannot add UNIQUE ({}) to `{table}`: existing rows contain \
duplicate values.",
columns.join(", "),
)));
}
let mut new_schema = old_schema.clone();
new_schema.unique_constraints.push(columns.to_vec());
let table_owned = table.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
do_describe_table(conn, table)
}
/// `ALTER TABLE <T> DROP CONSTRAINT <name>` (ADR-0035 §4g). Resolves
/// `name` to a named table-level CHECK on `T` (rebuild without it +
/// delete the metadata row), else to a named relationship (FK) whose
/// child is `T` (via `do_drop_relationship`), else refuses.
fn do_drop_constraint_by_name(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
name: &str,
) -> Result<Option<TableDescription>, DbError> {
reject_internal_table_name(table)?;
// 1. A named table-level CHECK on this table?
if check_table_has_name_column(conn)? {
let check_count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {CHECK_TABLE} \
WHERE table_name = ?1 AND name = ?2;"
),
[table, name],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if check_count > 0 {
let old_schema = read_schema(conn, table)?;
let mut new_schema = old_schema.clone();
new_schema
.check_constraints
.retain(|c| c.name.as_deref() != Some(name));
let (t, n) = (table.to_string(), name.to_string());
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
tx.execute(
&format!(
"DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;"
),
[t.as_str(), n.as_str()],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![t.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
return Ok(Some(do_describe_table(conn, table)?));
}
}
// 2. A named relationship (FK) whose child is this table?
let rel_count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1 AND child_table = ?2;"),
[name, table],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if rel_count > 0 {
return do_drop_relationship(
conn,
persistence,
source,
&RelationshipSelector::Named {
name: name.to_string(),
},
);
}
// 3. Not a known named constraint on this table.
Err(DbError::Sqlite {
message: format!("no such constraint: {name} on {table}"),
kind: SqliteErrorKind::Other,
})
}
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (<col>)
/// REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Resolves a bare
/// `REFERENCES <P>` to the parent's single-column PK, then delegates to
/// `do_add_relationship` (the same machinery `add 1:n relationship`
/// uses) with `create_fk = false` — the child column must already exist
/// (an `ALTER … ADD FOREIGN KEY` references an existing column).
fn do_alter_add_foreign_key(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
child_table: &str,
name: Option<&str>,
fk: &SqlForeignKey,
) -> Result<TableDescription, DbError> {
reject_internal_table_name(child_table)?;
reject_internal_table_name(&fk.parent_table)?;
let parent_column = match &fk.parent_column {
Some(c) => c.clone(),
None => {
let ps = read_schema(conn, &fk.parent_table)?;
if ps.primary_key.len() == 1 {
ps.primary_key[0].clone()
} else {
return Err(DbError::Unsupported(format!(
"`{parent}` has a composite primary key, so a bare reference \
is ambiguous name the referenced column, e.g. \
`REFERENCES {parent}(<col>)`.",
parent = fk.parent_table,
)));
}
}
};
do_add_relationship(
conn,
persistence,
source,
name,
&fk.parent_table,
&parent_column,
child_table,
&fk.child_column,
fk.on_delete,
fk.on_update,
false,
)
}
/// Create an index on `table` over `columns` (ADR-0025). /// Create an index on `table` over `columns` (ADR-0025).
/// ///
/// Refuses a redundant index on an already-indexed column set /// Refuses a redundant index on an already-indexed column set
+29
View File
@@ -739,6 +739,35 @@ pub enum AlterTableAction {
/// force flag; static-refused / incompatible still refuse). One undo /// force flag; static-refused / incompatible still refuse). One undo
/// step (the executor's rebuild). ADR-0035 §4f. /// step (the executor's rebuild). ADR-0035 §4f.
AlterColumnType { column: String, ty: Type }, AlterColumnType { column: String, ty: Type },
/// `ADD [CONSTRAINT <name>] (CHECK (…) | UNIQUE (…) | FOREIGN KEY
/// (…) REFERENCES …)` — a table-level constraint (ADR-0035 §4g). The
/// `name` is the `CONSTRAINT <name>` prefix (the FK carries its own
/// `SqlForeignKey::name`, set from this prefix at build time). CHECK
/// and FOREIGN KEY may be named; UNIQUE may not (composite UNIQUE is
/// anonymous in our model — §4g). Boxed: the FK payload is sizeable
/// (`clippy::large_enum_variant`).
AddTableConstraint {
name: Option<String>,
constraint: Box<TableConstraint>,
},
/// `DROP CONSTRAINT <name>` — drops a named table-level CHECK or a
/// named FK (relationship), resolved by name (ADR-0035 §4g).
DropConstraint { name: String },
}
/// A table-level constraint added via `ALTER TABLE … ADD [CONSTRAINT
/// <name>] …` (ADR-0035 §4g).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableConstraint {
/// `CHECK (<expr>)` — the expression as **raw SQL text** (the
/// `sql_expr` grammar is validate-only; the builder captures the
/// matched span — the 4a.2 / 4e mechanism).
Check { expr_sql: String },
/// `UNIQUE (<col>, …)` — a composite UNIQUE constraint.
Unique { columns: Vec<String> },
/// `FOREIGN KEY (<col>) REFERENCES <P>[(<col>)] [ON …]` — reuses the
/// 4b `SqlForeignKey` shape; decomposed to `add_relationship`.
ForeignKey(SqlForeignKey),
} }
impl std::fmt::Display for IndexSelector { impl std::fmt::Display for IndexSelector {
+331 -24
View File
@@ -14,7 +14,7 @@
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
}; };
use crate::dsl::value::Value; use crate::dsl::value::Value;
use crate::dsl::grammar::{ use crate::dsl::grammar::{
@@ -1880,21 +1880,29 @@ const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated {
min: 0, min: 0,
}; };
static AT_ADD_COLUMN_NODES: &[Node] = &[ // The walker's `Choice` selects a branch by its **leading** token and
Node::Word(Word::keyword("add")), // does not backtrack into a sibling once a branch's first keyword
// matched. So the action `Choice` keeps ONE branch per leading verb
// (`add`/`drop`/`rename`/`alter`); the `add` and `drop` verbs then
// fan out to an **inner** `Choice` whose branches each lead on a
// *distinct* second keyword (column / constraint / check / unique /
// foreign / primary), so no two same-led branches ever sit in one
// `Choice`.
// `add column <col> <type> [constraints]` — the column-def tail (the
// leading `add` is consumed by `AT_ADD`).
static AT_ADD_COLUMN_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("column")), Node::Word(Word::keyword("column")),
super::sql_create_table::COL_NAME, super::sql_create_table::COL_NAME,
super::sql_create_table::SQL_TYPE, super::sql_create_table::SQL_TYPE,
AT_ADD_CONSTRAINT_SUFFIX, AT_ADD_CONSTRAINT_SUFFIX,
]; ];
const AT_ADD_COLUMN: Node = Node::Seq(AT_ADD_COLUMN_NODES); const AT_ADD_COLUMN_TAIL: Node = Node::Seq(AT_ADD_COLUMN_TAIL_NODES);
static AT_DROP_COLUMN_NODES: &[Node] = &[ // `drop column <col>` / `drop constraint <name>` tails (leading `drop`
Node::Word(Word::keyword("drop")), // consumed by `AT_DROP`).
Node::Word(Word::keyword("column")), static AT_DROP_COLUMN_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("column")), COLUMN_NAME];
COLUMN_NAME, const AT_DROP_COLUMN_TAIL: Node = Node::Seq(AT_DROP_COLUMN_TAIL_NODES);
];
const AT_DROP_COLUMN: Node = Node::Seq(AT_DROP_COLUMN_NODES);
static AT_RENAME_COLUMN_NODES: &[Node] = &[ static AT_RENAME_COLUMN_NODES: &[Node] = &[
Node::Word(Word::keyword("rename")), Node::Word(Word::keyword("rename")),
@@ -1918,11 +1926,76 @@ static AT_ALTER_COLUMN_NODES: &[Node] = &[
]; ];
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES); const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);
// Each action branch leads on a concrete keyword (`add`/`drop`/`rename`/ // --- 4g: ADD [CONSTRAINT <name>] table-constraint / DROP CONSTRAINT ---
// `alter`) — trap-safe. (The branch's `alter` is the action word; the //
// entry-word `alter` was already consumed by dispatch.) // `ADD [CONSTRAINT <name>] (check (…) | unique (…) | foreign key (…)
static AT_ACTION_CHOICES: &[Node] = // references … | primary key (…))` and `DROP CONSTRAINT <name>`
&[AT_ADD_COLUMN, AT_DROP_COLUMN, AT_RENAME_COLUMN, AT_ALTER_COLUMN]; // (ADR-0035 §4g). The constraint bodies reuse the `sql_create_table`
// table-element nodes; the §4g name comes from the `CONSTRAINT <name>`
// prefix (a dedicated `constraint`-led inner branch — never a leading
// `Optional`). UNIQUE/PRIMARY KEY may carry a name syntactically but the
// builder refuses it (composite UNIQUE is anonymous; PK is unsupported).
const CONSTRAINT_NAME: Node = Node::Ident {
source: IdentSource::NewName,
role: "constraint_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// The constraint bodies — each leads on a distinct concrete keyword.
static AT_CONSTRAINT_BODY_CHOICES: &[Node] = &[
super::sql_create_table::TABLE_CHECK,
super::sql_create_table::TABLE_UNIQUE,
super::sql_create_table::TABLE_FK,
// `primary key (…)` parses so the builder can refuse it with a
// specific message rather than a generic "unexpected `primary`".
super::sql_create_table::TABLE_PK,
];
const AT_CONSTRAINT_BODY: Node = Node::Choice(AT_CONSTRAINT_BODY_CHOICES);
// `constraint <name> <body>` — the named-constraint tail (leads on the
// concrete `constraint` keyword, so it is a safe `Choice` sibling).
static AT_CONSTRAINT_NAMED_NODES: &[Node] = &[
Node::Word(Word::keyword("constraint")),
CONSTRAINT_NAME,
AT_CONSTRAINT_BODY,
];
const AT_CONSTRAINT_NAMED: Node = Node::Seq(AT_CONSTRAINT_NAMED_NODES);
// The `add` tail: a column def, a named constraint, or one of the bare
// (unnamed) constraint bodies — each branch leads on a distinct keyword
// (column / constraint / check / unique / foreign / primary).
static AT_ADD_TAIL_CHOICES: &[Node] = &[
AT_ADD_COLUMN_TAIL,
AT_CONSTRAINT_NAMED,
super::sql_create_table::TABLE_CHECK,
super::sql_create_table::TABLE_UNIQUE,
super::sql_create_table::TABLE_FK,
super::sql_create_table::TABLE_PK,
];
const AT_ADD_TAIL: Node = Node::Choice(AT_ADD_TAIL_CHOICES);
static AT_ADD_NODES: &[Node] = &[Node::Word(Word::keyword("add")), AT_ADD_TAIL];
const AT_ADD: Node = Node::Seq(AT_ADD_NODES);
// The `drop` tail: a column or a named constraint (distinct second
// keywords `column` / `constraint`).
static AT_DROP_CONSTRAINT_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("constraint")), CONSTRAINT_NAME];
const AT_DROP_CONSTRAINT_TAIL: Node = Node::Seq(AT_DROP_CONSTRAINT_TAIL_NODES);
static AT_DROP_TAIL_CHOICES: &[Node] = &[AT_DROP_COLUMN_TAIL, AT_DROP_CONSTRAINT_TAIL];
const AT_DROP_TAIL: Node = Node::Choice(AT_DROP_TAIL_CHOICES);
static AT_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_DROP_TAIL];
const AT_DROP: Node = Node::Seq(AT_DROP_NODES);
// One branch per leading verb (`add`/`drop`/`rename`/`alter`) — distinct
// concrete keywords, trap-safe. (The branch's `alter` is the action
// word; the entry-word `alter` was already consumed by dispatch.) The
// second-keyword fan-out happens in `AT_ADD` / `AT_DROP`'s inner Choice.
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME_COLUMN, AT_ALTER_COLUMN];
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES); const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);
static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[ static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
@@ -2040,18 +2113,27 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
Ok(AlterTableAction::AlterColumnType { column, ty }) Ok(AlterTableAction::AlterColumnType { column, ty })
} }
/// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f). The action is the /// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one
/// leading concrete keyword (`add`/`drop`/`rename`/`alter` — exactly one /// action `Choice` branch matched; the builder recovers which from the
/// matches per the action `Choice`). The `type` keyword is checked /// matched words. Discrimination order matters:
/// **first**: it is unique to ALTER COLUMN TYPE (ADD COLUMN's type is a ///
/// `col_type` *ident*, not the literal word), and an `alter column …` /// 1. **`type`** first — unique to ALTER COLUMN TYPE (ADD COLUMN's type
/// input contains none of add/drop/rename, so without this it would fall /// is a `col_type` *ident*, not the literal word), and an `alter
/// through to the DropColumn arm. /// column …` input also contains `column`, so it must be caught
/// before the column branch.
/// 2. **`column`** — the column ops (add/drop/rename column), routed by
/// `add`/`rename`/else-drop. Checked before the bare `add`/`drop`
/// keywords so `add column … unique`/`… check` (a column constraint)
/// still routes to AddColumn.
/// 3. **`add`** — a table-level constraint (CHECK / UNIQUE / FK / the
/// refused PRIMARY KEY).
/// 4. else **`drop`** — `drop constraint <name>`.
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> { fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
let table = require_ident(path, "table_name")?; let table = require_ident(path, "table_name")?;
let action = if path.contains_word("type") { let action = if path.contains_word("type") {
build_alter_column_type(path)? build_alter_column_type(path)?
} else if path.contains_word("add") { } else if path.contains_word("column") {
if path.contains_word("add") {
AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?)) AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
} else if path.contains_word("rename") { } else if path.contains_word("rename") {
AlterTableAction::RenameColumn { AlterTableAction::RenameColumn {
@@ -2062,10 +2144,111 @@ fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, Va
AlterTableAction::DropColumn { AlterTableAction::DropColumn {
column: require_ident(path, "column_name")?, column: require_ident(path, "column_name")?,
} }
}
} else if path.contains_word("add") {
build_alter_add_table_constraint(path, source)?
} else {
AlterTableAction::DropConstraint {
name: require_ident(path, "constraint_name")?,
}
}; };
Ok(Command::SqlAlterTable { table, action }) Ok(Command::SqlAlterTable { table, action })
} }
/// Build the `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY | …)`
/// action (ADR-0035 §4g). The body is discriminated by its leading
/// concrete keyword. The optional `CONSTRAINT <name>` prefix becomes the
/// action-level `name` (used by CHECK + FK at execution; refused on
/// UNIQUE). `ADD PRIMARY KEY` parses (for a clean message) but is
/// refused — every playground table already has a PK.
fn build_alter_add_table_constraint(
path: &MatchedPath,
source: &str,
) -> Result<AlterTableAction, ValidationError> {
let name = ident(path, "constraint_name").map(str::to_string);
if path.contains_word("primary") {
return Err(ValidationError {
message_key: "parse.custom.alter_add_primary_key",
args: vec![],
});
}
let constraint = if path.contains_word("check") {
TableConstraint::Check {
expr_sql: capture_table_check_sql(path, source)?,
}
} else if path.contains_word("unique") {
if name.is_some() {
return Err(ValidationError {
message_key: "parse.custom.alter_named_unique",
args: vec![],
});
}
TableConstraint::Unique {
columns: collect_idents(path, "unique_column"),
}
} else {
// FOREIGN KEY — the §4g name lives at the action level, so the
// FK body itself is parsed unnamed.
TableConstraint::ForeignKey(build_alter_fk(path))
};
Ok(AlterTableAction::AddTableConstraint {
name,
constraint: Box::new(constraint),
})
}
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
&& let Some((start, end)) = capture_parenthesised_span(&mut items)
{
return Ok(source[start..end].trim().to_string());
}
}
Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "add check needs an expression".to_string())],
})
}
/// Build the `SqlForeignKey` for an `ADD [CONSTRAINT <name>] FOREIGN KEY
/// (<col>) REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Mirrors the
/// table-level FK walk in `build_sql_create_table`, reusing
/// `consume_fk_reference`. The name is supplied at the action level (so
/// the FK is parsed unnamed here).
fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
let mut items = path.items.iter().peekable();
// Advance to the `foreign` keyword.
while items
.peek()
.is_some_and(|it| !matches!(it.kind, MatchedKind::Word("foreign")))
{
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
items.next();
}
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
items.next();
}
consume_fk_reference(&mut items, None, child_column)
}
pub static SQL_ALTER_TABLE: CommandNode = CommandNode { pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
entry: Word::keyword("alter"), entry: Word::keyword("alter"),
shape: SQL_ALTER_TABLE_SHAPE, shape: SQL_ALTER_TABLE_SHAPE,
@@ -2529,7 +2712,7 @@ mod sql_create_index_tests {
#[cfg(test)] #[cfg(test)]
mod sql_alter_table_tests { mod sql_alter_table_tests {
use crate::dsl::command::{AlterTableAction, ColumnSpec, Command}; use crate::dsl::command::{AlterTableAction, ColumnSpec, Command, TableConstraint};
use crate::dsl::parser::parse_command_in_mode; use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode; use crate::mode::Mode;
@@ -2696,6 +2879,130 @@ mod sql_alter_table_tests {
)); ));
} }
#[test]
fn add_table_check_unnamed_and_named() {
// ADR-0035 §4g: table-level CHECK, unnamed and named.
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
match alter("alter table T add constraint a_lt_b check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name.as_deref(), Some("a_lt_b"));
assert!(matches!(*constraint, TableConstraint::Check { .. }));
}
other => panic!("expected named AddTableConstraint/Check, got {other:?}"),
}
}
#[test]
fn add_composite_unique() {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
}
#[test]
fn named_unique_is_refused() {
// §4g: composite UNIQUE is anonymous in our model — naming it is
// refused by the BUILDER (it parses, then the builder rejects),
// so the error is the friendly message, not a parse error.
let err = parse_command_in_mode(
"alter table T add constraint u unique (a, b)",
Mode::Advanced,
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
#[test]
fn add_primary_key_is_refused() {
// §4g: adding a PK to an existing table is refused by the BUILDER
// (it parses for a clean message, then the builder rejects it).
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
#[test]
fn add_foreign_key_named_and_bare() {
// `add foreign key (col) references P(id)` and the bare
// `references P` form; named via the CONSTRAINT prefix.
match alter("alter table C add foreign key (pid) references P(id)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.child_column, "pid");
assert_eq!(fk.parent_table, "P");
assert_eq!(fk.parent_column.as_deref(), Some("id"));
}
other => panic!("expected ForeignKey, got {other:?}"),
}
}
other => panic!("expected AddTableConstraint/FK, got {other:?}"),
}
match alter("alter table C add constraint fk_p foreign key (pid) references P").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_column, None, "bare reference resolves at execution");
}
other => panic!("expected ForeignKey, got {other:?}"),
}
}
other => panic!("expected named AddTableConstraint/FK, got {other:?}"),
}
}
#[test]
fn drop_constraint_by_name() {
match alter("alter table T drop constraint a_lt_b").1 {
AlterTableAction::DropConstraint { name } => assert_eq!(name, "a_lt_b"),
other => panic!("expected DropConstraint, got {other:?}"),
}
}
#[test]
fn six_branch_dispatch_still_routes_column_actions() {
// The two new add/drop-constraint branches do not steal the four
// column actions.
assert!(matches!(
alter("alter table T add column note text").1,
AlterTableAction::AddColumn(_)
));
assert!(matches!(
alter("alter table T add column code text unique").1,
AlterTableAction::AddColumn(_),
));
assert!(matches!(
alter("alter table T drop column note").1,
AlterTableAction::DropColumn { .. }
));
assert!(matches!(
alter("alter table T rename column a to b").1,
AlterTableAction::RenameColumn { .. }
));
assert!(matches!(
alter("alter table T alter column a type text").1,
AlterTableAction::AlterColumnType { .. }
));
}
#[test] #[test]
fn alter_is_advanced_only() { fn alter_is_advanced_only() {
// No simple `alter`; in simple mode it does not parse as a // No simple `alter`; in simple mode it does not parse as a
+14 -4
View File
@@ -263,7 +263,10 @@ static TABLE_PK_NODES: &[Node] = &[
}, },
Node::Punct(')'), Node::Punct(')'),
]; ];
const TABLE_PK: Node = Node::Seq(TABLE_PK_NODES); // `pub(crate)` so `ALTER TABLE … ADD PRIMARY KEY (…)` (ADR-0035 §4g)
// reuses the body — it parses, then the ALTER builder refuses it with a
// specific message (adding a PK to an existing table is unsupported).
pub(crate) const TABLE_PK: Node = Node::Seq(TABLE_PK_NODES);
// Table-level `UNIQUE ( col, … )`. A single column normalises into // Table-level `UNIQUE ( col, … )`. A single column normalises into
// that column's `unique` flag (round-trips via the existing // that column's `unique` flag (round-trips via the existing
@@ -292,7 +295,9 @@ static TABLE_UNIQUE_NODES: &[Node] = &[
}, },
Node::Punct(')'), Node::Punct(')'),
]; ];
const TABLE_UNIQUE: Node = Node::Seq(TABLE_UNIQUE_NODES); // `pub(crate)` so `ALTER TABLE … ADD UNIQUE (…)` (ADR-0035 §4g) reuses
// the same composite-UNIQUE body.
pub(crate) const TABLE_UNIQUE: Node = Node::Seq(TABLE_UNIQUE_NODES);
// Table-level `CHECK ( <expr> )` (ADR-0035 §4a.3) — a multi-column // Table-level `CHECK ( <expr> )` (ADR-0035 §4a.3) — a multi-column
// CHECK referencing several columns. Same paren-bounded shape as the // CHECK referencing several columns. Same paren-bounded shape as the
@@ -306,7 +311,9 @@ static TABLE_CHECK_NODES: &[Node] = &[
Node::Subgrammar(&sql_expr::SQL_OR_EXPR), Node::Subgrammar(&sql_expr::SQL_OR_EXPR),
Node::Punct(')'), Node::Punct(')'),
]; ];
const TABLE_CHECK: Node = Node::Seq(TABLE_CHECK_NODES); // `pub(crate)` so `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (…)`
// (ADR-0035 §4g) reuses the same table-CHECK body.
pub(crate) const TABLE_CHECK: Node = Node::Seq(TABLE_CHECK_NODES);
// Table-level foreign key (ADR-0035 §5, sub-phase 4b): // Table-level foreign key (ADR-0035 §5, sub-phase 4b):
// `[CONSTRAINT <name>] FOREIGN KEY ( <child col> ) REFERENCES // `[CONSTRAINT <name>] FOREIGN KEY ( <child col> ) REFERENCES
@@ -356,7 +363,10 @@ static FOREIGN_KEY_BODY_NODES: &[Node] = &[
]; ];
const FOREIGN_KEY_BODY: Node = Node::Seq(FOREIGN_KEY_BODY_NODES); const FOREIGN_KEY_BODY: Node = Node::Seq(FOREIGN_KEY_BODY_NODES);
// `FOREIGN KEY (…) …` — the unnamed table-level FK (auto-named). // `FOREIGN KEY (…) …` — the unnamed table-level FK (auto-named).
const TABLE_FK: Node = FOREIGN_KEY_BODY; // `pub(crate)` so `ALTER TABLE … ADD [CONSTRAINT <name>] FOREIGN KEY
// (…)` (ADR-0035 §4g) reuses the FK body (the §4g `CONSTRAINT <name>`
// prefix is supplied by the ALTER grammar, not this body).
pub(crate) const TABLE_FK: Node = FOREIGN_KEY_BODY;
// `CONSTRAINT <name> FOREIGN KEY (…) …` — the named table-level FK. // `CONSTRAINT <name> FOREIGN KEY (…) …` — the named table-level FK.
static TABLE_FK_NAMED_NODES: &[Node] = &[ static TABLE_FK_NAMED_NODES: &[Node] = &[
Node::Word(Word::keyword("constraint")), Node::Word(Word::keyword("constraint")),
+2
View File
@@ -227,6 +227,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.caret", &["padding"]), ("parse.caret", &["padding"]),
// Custom (try_map / source-slice) error messages raised // Custom (try_map / source-slice) error messages raised
// by the DSL parser. See `parse.custom.*` in the catalog. // by the DSL parser. See `parse.custom.*` in the catalog.
("parse.custom.alter_add_primary_key", &[]),
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]), ("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]), ("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]), ("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
+11 -1
View File
@@ -274,7 +274,9 @@ help:
alter table <T> add column <col> <type> [not null] [unique] [default …] [check …] alter table <T> add column <col> <type> [not null] [unique] [default …] [check …]
alter table <T> drop column <col> alter table <T> drop column <col>
alter table <T> rename column <old> to <new> alter table <T> rename column <old> to <new>
alter table <T> alter column <col> type <type> — change a table's columns (advanced SQL) alter table <T> alter column <col> type <type>
alter table <T> add [constraint <name>] check (<expr>) | unique (<col>, …) | foreign key (<col>) references <P>[(<col>)]
alter table <T> drop constraint <name> — evolve a table's columns and constraints (advanced SQL)
drop: |- drop: |-
drop table <T> — remove a table drop table <T> — remove a table
drop column [from] [table] <T>: <col> [--cascade] — remove a column drop column [from] [table] <T>: <col> [--cascade] — remove a column
@@ -425,6 +427,12 @@ parse:
expression_too_deep: "expression nested too deeply" expression_too_deep: "expression nested too deeply"
on_action_specified_twice: "`on {target}` specified twice" on_action_specified_twice: "`on {target}` specified twice"
change_column_flags_exclusive: "`--force-conversion` and `--dont-convert` are mutually exclusive — pick one." change_column_flags_exclusive: "`--force-conversion` and `--dont-convert` are mutually exclusive — pick one."
# ADR-0035 §4g: adding a primary key to an existing table is not
# supported — every table is created with its primary key.
alter_add_primary_key: "a table's primary key is fixed at creation — `alter table … add primary key` is not supported."
# ADR-0035 §4g: composite UNIQUE constraints are unnamed in this
# tool, so a `constraint <name>` prefix on UNIQUE has nowhere to go.
alter_named_unique: "a UNIQUE constraint cannot be named — use `alter table <T> add unique (<col>, …)` without `constraint <name>`."
unknown_type: "unknown type '{found}' (expected one of: {expected})" unknown_type: "unknown type '{found}' (expected one of: {expected})"
unknown_action: "unknown referential action '{found}' (expected one of: {expected})" unknown_action: "unknown referential action '{found}' (expected one of: {expected})"
# Phase D typed-value-slot mismatch (ADR-0024 §Phase D): # Phase D typed-value-slot mismatch (ADR-0024 §Phase D):
@@ -475,6 +483,8 @@ parse:
alter table <Table> drop column <Name> alter table <Table> drop column <Name>
alter table <Table> rename column <Old> to <New> alter table <Table> rename column <Old> to <New>
alter table <Table> alter column <Name> type <Type> alter table <Table> alter column <Name> type <Type>
alter table <Table> add [constraint <Name>] check (<expr>) | unique (<col>, ...) | foreign key (<col>) references <Parent>[(<col>)]
alter table <Table> drop constraint <Name>
drop_table: "drop table <Name>" drop_table: "drop table <Name>"
drop_column: "drop column [from] [table] <Table>: <Name>" drop_column: "drop column [from] [table] <Table>: <Name>"
drop_relationship: |- drop_relationship: |-
+31 -6
View File
@@ -147,12 +147,37 @@ pub struct TableSchema {
/// optional on read. /// optional on read.
pub unique_constraints: Vec<Vec<String>>, pub unique_constraints: Vec<Vec<String>>,
/// Table-level `CHECK (<expr>)` constraints, in declaration /// Table-level `CHECK (<expr>)` constraints, in declaration
/// order, as raw SQL text (ADR-0035 §4a.3). The engine reports /// order, as raw SQL text with an optional name (ADR-0035 §4a.3,
/// no CHECK constraints, so these are the source of truth (held /// named in §4g). The engine reports no CHECK constraints, so these
/// in `__rdbms_playground_table_checks`) and echoed verbatim /// are the source of truth (held in
/// into the rebuilt DDL. Empty for project files written before /// `__rdbms_playground_table_checks`) and echoed verbatim into the
/// table-level CHECK existed — the YAML field is optional on read. /// rebuilt DDL. Empty for project files written before table-level
pub check_constraints: Vec<String>, /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
+103 -9
View File
@@ -23,7 +23,9 @@ use serde::Deserialize;
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type; 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. /// Serialize a `SchemaSnapshot` to a `project.yaml` body.
#[must_use] #[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) — // Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) —
// double-quoted (an expression like `a < b` is not a bare scalar) // 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() { if !table.check_constraints.is_empty() {
let _ = writeln!(out, " check_constraints:"); let _ = writeln!(out, " check_constraints:");
for expr in &table.check_constraints { for check in &table.check_constraints {
let _ = writeln!(out, " - {}", yaml_string(expr)); 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, primary_key: t.primary_key,
columns, columns,
unique_constraints: t.unique_constraints, 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()); 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. /// Optional on read — older project files omit it.
#[serde(default)] #[serde(default)]
unique_constraints: Vec<Vec<String>>, unique_constraints: Vec<Vec<String>>,
/// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3). /// Table-level CHECK constraints (ADR-0035 §4a.3, named in §4g).
/// Optional on read — older project files omit it. /// Optional on read — older project files omit it. Each entry is a
/// bare string (unnamed) or an `{expr, name}` mapping (named).
#[serde(default)] #[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)] #[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 }, 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()]], 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![], relationships: vec![],
indexes: 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] #[test]
fn check_constraints_optional_on_read() { fn check_constraints_optional_on_read() {
// A project file written before table-level CHECK existed (no // A project file written before table-level CHECK existed (no
+25
View File
@@ -33,6 +33,7 @@ use crate::db::{
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult, Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
QueryPlan, TableDescription, UpdateResult, QueryPlan, TableDescription, UpdateResult,
}; };
use crate::dsl::command::TableConstraint;
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec}; use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity; use crate::dsl::walker::Severity;
use crate::event::AppEvent; use crate::event::AppEvent;
@@ -2124,6 +2125,30 @@ async fn execute_command_typed(
.change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src) .change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src)
.await .await
.map(CommandOutcome::ChangeColumn), .map(CommandOutcome::ChangeColumn),
// `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)`
// (ADR-0035 §4g) — each reuses an existing low-level executor
// (the FK via the relationship machinery `add 1:n
// relationship` uses); one undo step each.
AlterTableAction::AddTableConstraint { name, constraint } => match *constraint {
TableConstraint::Check { expr_sql } => database
.alter_add_table_check(table, name, expr_sql, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
TableConstraint::Unique { columns } => database
.alter_add_unique(table, columns, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
TableConstraint::ForeignKey(fk) => database
.alter_add_foreign_key(table, name, fk, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
},
// `DROP CONSTRAINT <name>` — a named table-CHECK or a named
// FK, resolved by the executor (ADR-0035 §4g).
AlterTableAction::DropConstraint { name } => database
.alter_drop_constraint(table, name, src)
.await
.map(CommandOutcome::Schema),
}, },
Command::AddConstraint { Command::AddConstraint {
table, table,
+76 -2
View File
@@ -10,7 +10,8 @@
//! rename-drift bug that would break a later rebuild). //! rename-drift bug that would break a later rebuild).
use rdbms_playground::db::Database; use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, Type}; use rdbms_playground::dsl::command::Constraint;
use rdbms_playground::dsl::{ChangeColumnMode, ColumnSpec, ReferentialAction, Type};
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project; use rdbms_playground::project;
@@ -89,7 +90,7 @@ fn simple_column_ops_refuse_internal_tables() {
// happen to hit. // happen to hit.
let err = r let err = r
.block_on(db.change_column_type( .block_on(db.change_column_type(
internal, internal.clone(),
"table_name".to_string(), "table_name".to_string(),
Type::Int, Type::Int,
ChangeColumnMode::Default, ChangeColumnMode::Default,
@@ -100,6 +101,79 @@ fn simple_column_ops_refuse_internal_tables() {
format!("{err:?}").contains("NoSuchTable"), format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}" "expected a no-such-table refusal from the internal-table guard, got: {err:?}"
); );
// `add constraint` (the simple surface; also the SQL `ALTER TABLE …
// ADD CONSTRAINT` decomposition target — ADR-0035 §4g) is refused:
// the guard lives in `do_add_constraint`.
let err = r
.block_on(db.add_constraint(
internal,
"table_name".to_string(),
Constraint::NotNull,
None,
))
.expect_err("add constraint on an internal table is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal from the internal-table guard, got: {err:?}"
);
}
#[test]
fn add_relationship_refuses_internal_tables() {
// The guard lives in `do_add_relationship` (ADR-0035 §4g) and covers
// both the parent and the child endpoint — so the simple `add 1:n
// relationship` and the SQL `ALTER TABLE … ADD FOREIGN KEY` (which
// reaches the same executor) cannot touch an internal table.
let (_p, db, _d) = open();
let r = rt();
let internal = "__rdbms_playground_relationships".to_string();
// Internal *parent* — refused up-front.
let err = r
.block_on(db.add_relationship(
None,
internal.clone(),
"name".to_string(),
"C".to_string(),
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal parent is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal parent), got: {err:?}"
);
// Internal *child* — also refused (a real parent exists).
r.block_on(db.sql_create_table(
"P".to_string(),
vec![ColumnSpec::new("id", Type::Int)],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table P (id int primary key)".to_string()),
))
.expect("create P");
let err = r
.block_on(db.add_relationship(
None,
"P".to_string(),
"id".to_string(),
internal,
"x".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
))
.expect_err("relationship with an internal child is refused");
assert!(
format!("{err:?}").contains("NoSuchTable"),
"expected a no-such-table refusal (internal child), got: {err:?}"
);
} }
#[test] #[test]
+238
View File
@@ -358,3 +358,241 @@ fn e2e_alter_column_type_is_one_undo_step() {
); );
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type"); assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type");
} }
// --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) -----------
/// True if inserting `(id, qty)` into table `T` succeeds.
fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64) -> bool {
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number(id.to_string()), Value::Number(qty.to_string())],
Some("insert".to_string()),
))
.is_ok()
}
#[test]
fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
// ADD a named table-CHECK; it is enforced; it round-trips through a
// rebuild *with its name* — proven by DROP CONSTRAINT <name> still
// resolving after the rebuild (the name reached project.yaml and back).
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("c.commands"),
"create table T with pk id(int)\n\
add column T: qty (int)\n\
alter table T add constraint qty_positive check (qty >= 0)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "c.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 3),
"events: {events:?}"
);
// Enforced: qty = -1 refused, qty = 5 accepted.
assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1");
assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK");
// Rebuild from text, then DROP CONSTRAINT by name must still work →
// the name survived the round-trip.
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild");
assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild");
std::fs::write(
project.path().join("drop.commands"),
"alter table T drop constraint qty_positive\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "drop.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
"DROP CONSTRAINT resolved the name after rebuild; events: {events:?}"
);
// After the drop the CHECK no longer applies: qty = -1 is accepted.
assert!(insert_t_qty_ok(&db, &r, 4, -1), "the CHECK was dropped");
}
#[test]
fn e2e_add_check_with_violating_data_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
add column T: qty (int)\n\
insert into T (id, qty) values (1, -5)\n\
alter table T add check (qty >= 0)\n",
),
"adding a CHECK that existing rows violate is refused"
);
}
#[test]
fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("u.commands"),
"create table T with pk id(int)\n\
add column T: a (int)\n\
add column T: b (int)\n\
insert into T (id, a, b) values (1, 1, 2)\n\
alter table T add unique (a, b)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
let dup_ok = |id: i64, a: i64, b: i64| {
r.block_on(db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
vec![
Value::Number(id.to_string()),
Value::Number(a.to_string()),
Value::Number(b.to_string()),
],
Some("insert".to_string()),
))
.is_ok()
};
assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)");
assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted");
// Survives rebuild (the unique_constraints yaml path).
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild");
assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild");
}
#[test]
fn e2e_add_unique_with_duplicate_data_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
add column T: a (int)\n\
insert into T (id, a) values (1, 7)\n\
insert into T (id, a) values (2, 7)\n\
alter table T add unique (a)\n",
),
"adding a UNIQUE that existing rows violate is refused"
);
}
#[test]
fn e2e_add_foreign_key_creates_an_enforced_relationship() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("fk.commands"),
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
insert into P (id) values (1)\n\
alter table C add constraint c_to_p foreign key (pid) references P(id)\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
// FK enforced: a child row referencing a missing parent is rejected;
// one referencing the existing parent (id = 1) is accepted.
let insert_c = |cid: i64, pid: i64| {
r.block_on(db.insert(
"C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())],
Some("insert".to_string()),
))
};
assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted");
assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected");
}
#[test]
fn e2e_drop_constraint_removes_a_named_foreign_key() {
let (project, db, _d) = open();
let r = rt();
std::fs::write(
project.path().join("fk.commands"),
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
alter table C add constraint c_to_p foreign key (pid) references P(id)\n\
alter table C drop constraint c_to_p\n",
)
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "fk.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 5),
"events: {events:?}"
);
// The FK is gone: a child referencing a missing parent now succeeds.
assert!(
r.block_on(db.insert(
"C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("999".to_string())],
Some("insert".to_string()),
))
.is_ok(),
"after DROP CONSTRAINT the FK no longer applies"
);
}
#[test]
fn e2e_constraint_name_collision_is_refused() {
// A named CHECK cannot reuse a relationship (FK) name on the same
// table — both are `DROP CONSTRAINT <name>` targets, so a collision
// would make the drop ambiguous.
assert!(
replay_is_refused(
"create table P with pk id(int)\n\
create table C with pk cid(int)\n\
add column C: pid (int)\n\
alter table C add constraint dup foreign key (pid) references P(id)\n\
alter table C add constraint dup check (cid > 0)\n",
),
"a CHECK reusing an existing FK name on the table is refused"
);
}
#[test]
fn e2e_drop_unknown_constraint_is_refused() {
assert!(
replay_is_refused(
"create table T with pk id(int)\n\
alter table T drop constraint nope\n",
),
"dropping a non-existent constraint is refused"
);
}
#[test]
fn e2e_add_constraint_is_one_undo_step() {
// ADD CONSTRAINT CHECK is one rebuild = one undo step; driven through
// the full SQL pipeline, then undone in one.
let (project, db, _d) = open_with_undo();
let r = rt();
std::fs::write(
project.path().join("c.commands"),
"create table T with pk id(int)\n\
add column T: qty (int)\n\
insert into T (id, qty) values (1, 5)\n\
alter table T add constraint qty_positive check (qty >= 0)\n",
)
.expect("write");
r.block_on(run_replay(&db, project.path(), "c.commands"));
assert!(!insert_t_qty_ok(&db, &r, 2, -1), "the CHECK is enforced");
assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"the ADD CONSTRAINT was one undo step"
);
// After undo the CHECK is gone: qty = -1 is accepted.
assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK");
}