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.
8.5 KiB
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:
- SQLite's
ALTER TABLEcannot add or drop aFOREIGN KEYconstraint 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. - 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 ("thecust_ordersrelationship") 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 keywordasis required to introduce the name so the parser is unambiguous in the presence offrom(an unkeyed identifier could otherwise be confused with the parent table identifier).on delete/on updateclauses are independently optional and can appear in either order; default action isno action.--create-fkauto-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:
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_keysis toggled at the connection level outside the transaction, because the pragma is a no-op inside one.- The
IF EXISTSclause 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 asNULLin existing rows. foreign_key_checkruns 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 tablebecomes 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)