# 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 ] from . to . [on delete ] [on update ] [--create-fk] drop relationship drop relationship from . to . ``` - `as ` 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 ` is omitted, the executor generates `__to__`, matching the direction of the user's `from . to .` 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_; CREATE TABLE __rdbms_rebuild_ (...) STRICT; INSERT INTO __rdbms_rebuild_ (cols-on-both) SELECT (cols-on-both) FROM ; DROP TABLE ; ALTER TABLE __rdbms_rebuild_ RENAME TO ; 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 [on delete ] [on update ]` 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 ` 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)