Files
rdbms-playground/docs/adr/0013-relationships-and-rebuild-table.md
T
claude@clouddev1 165068269b Foreign-key relationships, rebuild-table, polish round
DSL:
- add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
  [on delete <action>] [on update <action>] [--create-fk]
- drop relationship <name> | from <P>.<col> to <C>.<col>
- show table <name> for re-displaying a structure on demand

Database (ADR-0013):
- Rebuild-table primitive following SQLite's
  ALTER-via-rebuild recipe (foreign_keys=OFF outside tx,
  copy-by-name, foreign_key_check before commit). Reusable for
  B2 (column drops/renames/type changes).
- ReferentialAction enum (no action / restrict / set null /
  cascade); SET DEFAULT awaits column DEFAULTs.
- __rdbms_playground_relationships metadata table -- names,
  auto-generated as <Parent>_<pcol>_to_<Child>_<ccol>.
- Type::fk_target_type() validation at declaration; friendly
  errors for type mismatch, non-PK target, missing column,
  duplicate name.
- describe_table populates symmetric outbound + inbound
  relationship lists. drop_table refuses while inbound
  references exist; outbound metadata cleaned up alongside drop.

App / UI:
- In-line cursor editing in the input field: Left, Right,
  Home, End, Delete, Backspace honoring UTF-8 boundaries.
- PageUp / PageDown scrolls the output buffer; viewport row
  count fed back from the renderer via App::note_output_viewport
  so scroll is capped against the actual visible area
  (regression-tested) and snaps to the bottom on new output.
- Failure messages quote the command portion ("verb target"
  failed: ...) for visual clarity; RelationshipSelector has a
  proper Display impl so "no such relationship" reads cleanly.
- Structure rendering shows References / Referenced by sections.

Docs:
- ADR-0013 covers naming, metadata table, symmetric view, and
  the rebuild-table strategy.
- requirements.md updates: C3 (FK done), B2 (primitive in),
  T3 (compound-PK FK still pending). New entries: I1a (cursor
  editing -- landed), I1b (Ctrl-A/E and readline shortcuts --
  pending), V4 partial scroll, V5 (show family), C3a (modify
  relationship -- deferred).

Tests: 154 passing (140 lib + 14 integration), 0 skipped.
Clippy clean with nursery enabled.
2026-05-07 14:52:51 +00:00

235 lines
8.5 KiB
Markdown

# ADR-0013: Relationships, naming, and the rebuild-table strategy
## Status
Accepted
## Context
This iteration introduces foreign-key relationships between
tables (C3 partial), which exposes two coupled design problems:
1. **SQLite's `ALTER TABLE` cannot add or drop a `FOREIGN KEY`
constraint** on an existing table. The accepted SQLite recipe
for this is the rebuild-table dance (create a new table with
the desired shape, copy the data, drop the old, rename). This
same machinery is what B2 (column drops/renames/type changes)
needs in future iterations, so the cost is shared.
2. **SQL foreign-key constraints have no user-name slot.** A
relationship is identified internally by its `(child, column,
parent, column)` quadruple. Pedagogically, the user's mental
model is more natural with named relationships ("the
`cust_orders` relationship") that survive renaming and feature
in drop / future modify commands. A name field has to come
from somewhere we own.
Additionally, the SQLite schema model treats a foreign key as a
property of the *child* table only. A learner viewing the parent
table normally has no clue that other tables refer to it. This
asymmetry runs counter to the relational mental model.
## Decision
### Grammar
Relationships are declared and removed via DSL commands that
follow ADR-0009 (required clauses keyword-based; `--` flags for
opt-ins):
```
add 1:n relationship [as <name>] from <Parent>.<col> to <Child>.<col>
[on delete <action>]
[on update <action>]
[--create-fk]
drop relationship <name>
drop relationship from <Parent>.<col> to <Child>.<col>
```
- `as <name>` is optional. The keyword `as` is required to
introduce the name so the parser is unambiguous in the
presence of `from` (an unkeyed identifier could otherwise be
confused with the parent table identifier).
- `on delete` / `on update` clauses are independently optional
and can appear in either order; default action is `no action`.
- `--create-fk` auto-creates the child column with the
appropriate type (`Type::fk_target_type()` of the parent
column, per ADR-0011) when it does not yet exist. Without the
flag, missing child column is a friendly error advising the
user to add the column first or use the flag.
- Drop accepts both forms — the named form for users who picked
a name; the positional form for users who let it auto-generate.
### Auto-name format
When `as <name>` is omitted, the executor generates
`<Parent>_<parent_col>_to_<Child>_<child_col>`, matching the
direction of the user's `from <Parent>.<col> to <Child>.<col>`
syntax. Example: a relationship from `Customers.id` to
`Orders.CustId` is named `Customers_id_to_Orders_CustId`. Long
but unambiguous, and reads in the same direction as the
declaration.
### Internal metadata
A new internal table tracks relationship metadata:
```sql
CREATE TABLE __rdbms_playground_relationships (
name TEXT NOT NULL UNIQUE,
parent_table TEXT NOT NULL,
parent_column TEXT NOT NULL,
child_table TEXT NOT NULL,
child_column TEXT NOT NULL,
on_delete TEXT NOT NULL,
on_update TEXT NOT NULL,
PRIMARY KEY (child_table, child_column)
) STRICT;
```
The PK on `(child_table, child_column)` enforces our convention
"at most one FK per child column" — true in typical SQL practice
even though SQLite would technically allow more. The unique
`name` ensures no two relationships collide in identification.
Created at connection open alongside `__rdbms_playground_columns`
(ADR-0012); follows the `__rdbms_*` internal-table naming
convention. `list_tables` filters this out of user-visible
listings.
### Symmetric description
`describe_table` populates two collections on `TableDescription`:
- `outbound_relationships` — relationships where this table is
the *child* (it has the FK column).
- `inbound_relationships` — relationships where this table is
the *parent* (other tables reference one of its PK columns).
Both are populated by querying the relationships metadata table
(child-side for outbound; parent-side for inbound), which keeps
the symmetric view consistent with the underlying constraints
that SQLite enforces.
The structure view in the output panel renders both sections
when present:
```
Customers
Id [serial PK]
Email [text]
Referenced by:
Orders.CustId → Id (Customers_id_to_Orders_CustId, on delete cascade, on update no action)
Orders
Id [serial PK]
CustId [int]
References:
CustId → Customers.Id (Customers_id_to_Orders_CustId, on delete cascade, on update no action)
```
### Type compatibility check
Per ADR-0011, the FK column type must match the parent column's
`fk_target_type()`. The executor checks this:
- For `--create-fk`: the column is created with that type, no
user-side decision required.
- For an existing column: type is compared. Mismatch yields a
friendly error naming both types and the expected one. The
user learns through the error rather than via a silent
promotion.
The non-PK-target case (FK referencing a non-PK column) is
rejected with a clear error. UNIQUE-target FKs land when
UNIQUE constraints (C3 partial) do.
### Rebuild-table dance
The `rebuild_table` primitive performs the SQLite-recommended
ALTER-via-rebuild sequence:
```
PRAGMA foreign_keys = OFF; -- must be outside any tx
BEGIN;
DROP TABLE IF EXISTS __rdbms_rebuild_<name>;
CREATE TABLE __rdbms_rebuild_<name> (...) STRICT;
INSERT INTO __rdbms_rebuild_<name> (cols-on-both)
SELECT (cols-on-both) FROM <name>;
DROP TABLE <name>;
ALTER TABLE __rdbms_rebuild_<name> RENAME TO <name>;
<metadata updates>
PRAGMA foreign_key_check; -- abort on any returned rows
COMMIT;
PRAGMA foreign_keys = ON;
```
Key properties:
- `foreign_keys` is toggled at the connection level outside the
transaction, because the pragma is a no-op inside one.
- The `IF EXISTS` clause defends against a leftover rebuild
table from a previous failed attempt.
- Data is copied **column-by-name** — only columns present on
both old and new schemas are transferred. Auto-created columns
(e.g. via `--create-fk`) start as `NULL` in existing rows.
- `foreign_key_check` runs before commit. Any returned rows mean
existing data violates the new constraint, which we surface as
a clear error and roll back.
- The metadata-updates closure runs inside the same transaction
so the schema and the metadata stay consistent.
This primitive is reused by `add_relationship` and
`drop_relationship`. Future B2 work (drop column, rename column,
change column type) plugs into the same primitive without
re-implementing the dance.
### Drop-table interaction
A `drop table` request is rejected when the table has *inbound*
relationships — dropping it would leave dangling FK constraints
in the children. The error names the offending relationships;
the user drops those first, then drops the table.
Outbound relationships are removed naturally with the table; the
metadata rows are cleaned up alongside the drop in the same
transaction.
### Modify deferred
`modify relationship <name> [on delete <action>] [on update
<action>]` would be a one-step way to change actions without
typing both the drop and the add. The mechanics reuse the
rebuild dance. **Deferred** to a follow-up iteration; users can
achieve the same via drop + add today.
## Consequences
- Pedagogically honest: the user creates tables, adds columns,
declares relationships in the natural order. Type mismatches
and missing columns are caught at declaration time with
actionable errors.
- The rebuild-table primitive is the load-bearing piece for B2
too; future column drops, renames, and type changes layer on
top.
- The relationship metadata table is a new authority alongside
the column metadata table (ADR-0012). Both are owned by the
app and round-trip through the project file format (track 2)
when that lands.
- Without I/U/D (C5) the FK constraints are observable but not
yet *demonstrable* (you can't try inserting a violating row).
C5 is the obvious follow-up.
- The `as <name>` keyword is established. Future commands that
accept optional names should follow the same convention to
stay parser-unambiguous.
- `drop table` becomes more conservative — it now refuses when
inbound relationships exist. This is a learnability win
(failure has a clear cause) and matches relational expectations.
## See also
- ADR-0009 (DSL command syntax conventions)
- ADR-0010 (DB worker thread)
- ADR-0011 (FK column type compatibility)
- ADR-0012 (Internal column metadata)