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

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:

  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:

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)