165068269b
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.
235 lines
8.5 KiB
Markdown
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)
|