writeonce/docs/plan/exploration/postgresql/constraints-and-grammar.md
shoney.arickathil 35c32d9b0f docs: postgres study — constraints/grammar + indexing cards; subagent guide
- constraints-and-grammar: gram.y PK/FK productions, pg_constraint,
  RI trigger semantics; writeonce direction — @key as unique alias
  (id stays THE key), ref actions (@on_delete), backlink-implies-index
  (improves on postgres' not-auto-created FK index)
- indexing-and-point-lookup: AM roster + algorithms (Lehman-Yao,
  linear hashing), TID = row address; writeonce gap — probe walks
  slabs while idx_bucket exists; O(1) slice direction, non-goals
- card index updated; Rust-era plan-10/11/12 links unlinked (rot)
- docs/guides/database-developer-subagent.md: format, paste-ready
  agent definition (doctrine/file map/gates), verification, division
  of labor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 16:13:48 +02:00

102 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Constraints & DDL grammar — PK / FK / reverse navigation
What Postgres' CREATE TABLE grammar and catalog do for PRIMARY KEY,
FOREIGN KEY/REFERENCES, and reverse lookup — and the `@table` grammar
writeonce should grow from it. Tree: post-18 master
(`REL_18_BETA1-2871`); paths into
[`reference/postgresql/`](../../../../.dev/reference/postgresql/).
## The Postgres side (facts, with paths)
**Grammar** (`src/backend/parser/gram.y`):
- Column constraints (`ColConstraintElem`, :4119): `UNIQUE` (:4140,
with `NULLS [NOT] DISTINCT`), `PRIMARY KEY` (:4153), and
`REFERENCES qualified_name opt_column_list key_match key_actions`
(:4224).
- Table-level twins (`ConstraintElem`, :4376): multi-column
`UNIQUE (...)` :4404, `PRIMARY KEY (...)` :4439 (or adopt an
existing index, :4457), `FOREIGN KEY (...) REFERENCES ...` :4493.
- REFERENCES options: `key_match` = MATCH FULL | PARTIAL
(unimplemented, errors) | SIMPLE (default) — :4620; `key_actions` =
`ON UPDATE`/`ON DELETE` × { NO ACTION | RESTRICT | CASCADE |
SET NULL | SET DEFAULT } — :4664–4733. Single-char codes in
`src/include/nodes/parsenodes.h:2928`.
**Catalog** (`src/include/catalog/pg_constraint.h`):
- One row per constraint; `contype` `'p'`/`'f'`/`'u'` (:198). FK rows
carry the FORWARD direction only: `conrelid`/`conkey[]` (referencing)
→ `confrelid`/`confkey[]` (referenced), plus the action/match chars
(:98–130).
- **A PK/UNIQUE constraint IS an index**: `transformIndexConstraints`
(`src/backend/parser/parse_utilcmd.c:2245`) rewrites the constraint
into an `IndexStmt` (`index->primary`, `index->unique`) — the
constraint and its unique index are one object (`conindid`,
`index_constraint_create`, `catalog/index.c:1903`). FKs are
transformed AFTER indexes deliberately (:3023).
**FK enforcement = trigger pairs** (`utils/adt/ri_triggers.c`):
- Referencing side: INSERT/UPDATE fire `RI_FKey_check` (:358) —
`SELECT 1 FROM <pktable> WHERE pk = $1 FOR KEY SHARE` (a probe of the
PK's unique index; :452 even has a direct-index fast path bypassing
SPI).
- Referenced side: DELETE/UPDATE fire the action triggers —
restrict/noaction = `SELECT 1 FROM <fktable> WHERE fk = $1` (:903),
cascade = `DELETE FROM <fktable> WHERE fk = $1` (:1089), setnull/
setdefault = the obvious UPDATEs. NO ACTION vs RESTRICT differ only
in deferrability + a replacement-row re-check (:872) — "the only
difference", per the source comment.
**Reverse navigation — the load-bearing negative:**
- Postgres stores NO backlink. A reverse lookup is a plain scan of the
referencing table (`WHERE $1 = fkatt1`); it is fast iff an index on
the FK column exists. That index is recommended, NOT auto-created
(`doc/src/sgml/ddl.sgml:1390`), because indexing choices vary. The
referenced side, by contrast, ALWAYS has an index — it must be a
PK/unique.
## The writeonce translation
What exists today: every class is a table; the auto-assigned,
shard-interleaved `id` is the de-facto primary key (O(1) via the
table's open-addressing id hash); `@table(name:, index: [cols])`
declares secondary indexes; `@unique` on a column; `ref T` is a stored
FK (restrict-only, checked by `wo_row_has_referrers` — currently a
full scan); `backlink T.field` is a declared reverse view (currently
an O(table) scan too).
The grammar this study argues for (words, no code — an iteration's
brainstorm decides):
1. **Keep the id as THE primary key; add `@key` as a UNIQUE ALIAS, not
a replacement.** Postgres' lesson: a PK is just a unique index the
catalog blesses (`transformIndexConstraints`). writeonce already has
the blessed unique id; a user-declared `@key` on a column should
desugar to `@unique` + the natural-lookup index — never a second
row-identity (slabs, WAL records, and refs all speak id).
2. **`ref T` grows an action option, defaulting to today's behavior:**
`ref T` = restrict (current semantics, now named); optional
`@on_delete(cascade)` / `@on_delete(set_nil)` — the `?ref T` shape
is the precondition for set_nil, exactly as SET NULL requires a
nullable column in Postgres. MATCH variants: skip — single-column
refs only, MATCH SIMPLE semantics by construction.
3. **Backlink beats Postgres — if it implies the index.** Postgres
makes reverse lookup fast only when the user remembers the FK-column
index; writeonce's `backlink T.field` is a DECLARED intent, so the
compiler should auto-require `index: [field]` on the referencing
table (or inject it) — the study's one clear improvement over the
reference. `wo_row_has_referrers` and backlink reads then become
index probes, not scans (see the indexing card).
4. **Enforcement placement:** Postgres bolts FK checks on as triggers
because constraints arrived after the executor; writeonce's choke
points (`wo_row_insert`/`wo_row_remove`/`wo_row_update_field`) are
the honest home — checks stay inline, no trigger machinery, same
observable semantics (insert probes the referenced id's existence;
delete probes the referencing index).
Non-goals this study records: composite keys (no driving workload),
deferrable constraints (need `transaction { }` = held iteration 18),
MATCH PARTIAL (Postgres never shipped it either).