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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
# 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)
|
||||
@@ -18,3 +18,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0010 — Database access via a dedicated worker thread](0010-database-access-via-worker-thread.md)
|
||||
- [ADR-0011 — Foreign-key column type compatibility](0011-fk-column-type-compatibility.md)
|
||||
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
|
||||
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
|
||||
|
||||
Reference in New Issue
Block a user