Files
rdbms-playground/docs/adr/0025-indexes.md
T
claude@clouddev1 701217d29f feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX
Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON
<T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> ->
SqlDropIndex, both reusing the ADR-0025 executors (do_add_index /
do_drop_index), like 4c reused do_drop_table.

- CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1):
  ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced
  mode trusts the user like SQL does. Adds an additive IndexSchema.unique
  flag (project.yaml, serde-default, version stays 1); rebuild re-emits
  CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique).
  Simple-mode `add unique index` stays deferred.
- IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip
  (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome.
- Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE]
  prefix is a concrete-keyword Choice and the optional name an on-led-first
  selector (the drop-index selector precedent) — trap-safe.
- create/drop each gain a second advanced node; the existing all-candidates
  dispatch handles it (locked by parse tests).
- Unique indexes marked [unique] in the structure view and items panel.
- do_add_index refuses internal __rdbms_* tables as "no such table",
  closing a latent exposure on both the simple `add index` and the new
  SQL CREATE INDEX surfaces (ADR-0025 Amendment 1).

Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README;
requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md.

Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
2026-05-25 18:54:32 +00:00

389 lines
15 KiB
Markdown

# ADR-0025: Indexes
## Status
Accepted. **Amendment 1 (2026-05-25):** UNIQUE indexes are admitted on
the **advanced-mode** SQL surface (`CREATE UNIQUE INDEX`) — see
*Amendment 1* below and ADR-0035 §4d. The original *Out of scope*
exclusion stands for the **simple-mode DSL** (`add unique index` remains
deferred).
## Context
The requirements checklist (`C3`) commits to indexes as part
of the schema-constraint surface, and `S2` commits to the
items list showing "tables and per-table indexes". Neither is
implemented yet.
Indexes are the natural next teaching topic after relationships:
they are the structure that makes `EXPLAIN QUERY PLAN` (`QA1`)
pedagogically interesting — the plan for a filtered query
visibly changes from a full scan to an index search once an
index exists. `QA1` itself is a deliberate follow-up (it needs
its own rendering ADR and a query worth explaining); this ADR
sets it up by giving the playground real indexes.
Three design problems shape the decision:
1. **SQLite owns the index namespace.** Unlike foreign keys —
which have no name slot, the problem ADR-0013 solved with an
internal metadata table — an index in SQLite *is* a named
object. `sqlite_master` and `PRAGMA index_list` /
`index_info` carry the name, table, column list, and
uniqueness natively. There is nothing app-specific to store.
2. **`DROP TABLE` silently drops a table's indexes.** The
rebuild-table primitive (ADR-0013) — used by change-column-
type and every relationship operation — drops and recreates
the table. Once indexes exist, every such operation would
erase them unless the primitive is taught to preserve them.
3. **`playground.db` is a derived artifact** (ADR-0004 /
ADR-0015). Indexes must round-trip through `project.yaml`
or they vanish on `rebuild`, `export`, and `import`.
## Decision
### Grammar
Indexes are declared and removed via DSL commands following
ADR-0009 (required clauses keyword-based; optional names
introduced by `as` per the ADR-0013 convention; `--` flags for
opt-ins):
```
add index [as <name>] on <Table> (<col>[, <col>...])
drop index <name>
drop index on <Table> (<col>[, <col>...])
```
- `add index` is a third branch of the existing `add`
command, alongside `add column` and `add 1:n relationship`;
`drop index` is a new branch of the existing `drop` command.
- `as <name>` is optional. The `as` keyword introduces the
name, matching `add 1:n relationship [as <name>]` (ADR-0013
established `as` as the convention for optional names).
- `on <Table>` uses the keyword `on` — the SQL-natural word
for `CREATE INDEX ... ON table`, and pedagogically aligned.
- The column list is parenthesised and comma-separated, the
same shape as `create table` and `insert`. One or more
columns; multiple columns produce a composite index in the
given order. An empty list `()` is a parse error.
- Column-list completion resolves against the named table,
reusing the dynamic-subgrammar mechanism that already drives
`insert into T (...)` column candidates.
`add unique index` is **not** part of this ADR — see
*Out of scope*.
### Auto-name format
When `as <name>` is omitted, the executor generates
`<Table>_<col1>[_<col2>...]_idx`, mirroring the descriptive,
subject-first style of ADR-0013's relationship auto-names.
Examples:
- `add index on Customers (email)``Customers_email_idx`
- `add index on Orders (CustId, Date)``Orders_CustId_Date_idx`
If the generated name is already taken — which happens exactly
when the same columns of the same table are already indexed —
the command is refused with a friendly error naming the
existing index (a second index on an identical column set is
redundant). A duplicate *explicit* name is likewise a friendly
error.
### Drop forms
`drop index` accepts two forms, mirroring `drop relationship`:
- `drop index <name>` — for users who named the index or know
the generated name.
- `drop index on <Table> (<col>...)` — the positional form,
resolved by matching the table and exact column set against
the table's indexes. No match is a friendly error; more than
one match is an ambiguity error listing the candidates and
advising the user to drop by name.
### Storage — no metadata table
Indexes do **not** get a `__rdbms_playground_indexes` table.
SQLite stores everything the application needs natively:
- `PRAGMA index_list(<table>)` — index name, uniqueness, and
`origin` (`c` = `CREATE INDEX`, `u` = UNIQUE constraint,
`pk` = primary key).
- `PRAGMA index_info(<index>)` — the ordered column list.
The application reads indexes through these pragmas. Only
`origin = 'c'` indexes are treated as user indexes; the
automatic indexes SQLite creates to back primary keys and
UNIQUE constraints are not surfaced as user indexes.
This is a deliberate divergence from the ADR-0013 relationship
precedent. Relationships needed a metadata table because SQL
foreign keys have no name slot; indexes have one, so the
divergence is justified — adding a metadata table would
duplicate state SQLite already owns and create a consistency
hazard.
The in-memory representation is a small structural value
(`name`, `table`, ordered `columns`) carried by `db.rs`,
`persistence`, and the renderer.
### `project.yaml` persistence
A top-level `indexes:` list is added to `project.yaml`,
mirroring `relationships:`. Each entry records the index name,
its table, and its ordered column list:
```yaml
indexes:
- name: Customers_email_idx
table: Customers
columns: [email]
```
- `version:` stays `1`. The field is additive and optional:
the `serde_yml` reader marks it `#[serde(default)]`, so
project files written before this change parse unchanged. No
migrator is required (the ADR-0015 §F3 framework stays
empty).
- The hand-rolled writer emits `indexes: []` when there are
none, consistent with how `tables`/`relationships` render.
- `SchemaSnapshot` gains an `indexes` vector alongside
`tables` and `relationships`.
- `rebuild_from_text` recreates each index (via
`CREATE INDEX`) after the tables are built. `export` /
`import` carry indexes because they operate on the text
artifacts.
### Rebuild-table interaction
The `rebuild_table` primitive (ADR-0013) is extended so it no
longer loses indexes:
1. **Before** the `DROP TABLE`, capture the table's user
indexes structurally (name + ordered columns) via
`PRAGMA index_list` / `index_info`, filtered to
`origin = 'c'`.
2. **After** the `ALTER TABLE ... RENAME`, recreate them with
`CREATE INDEX`.
Recreation is parameterised by an optional column-rename map
and a set of dropped columns, so the same primitive serves
every caller:
- **add / drop relationship**, **change column type** — the
column set is unchanged; indexes are recreated verbatim.
- **rename column** — an index referencing the old column name
is regenerated with the new name; the index keeps its own
name. No error.
- **drop column** — see below.
Because indexes are captured structurally (not as raw SQL
text), regeneration after a rename is a clean substitution
rather than SQL string-munging.
### Drop / rename column interaction
- **rename column** and **change column type** preserve any
covering index transparently, per the rebuild rules above.
- **drop column** is refused by default when an index covers
the dropped column. The error names the offending index(es)
and advises dropping them first. This matches the existing
conservative posture of `drop column`, which already refuses
primary-key and FK-involved columns.
- A new `--cascade` flag on `drop column` opts in to the
cascading behaviour: covering indexes are dropped
automatically and each is reported in the result note.
```
drop column <col> from table <Table> [--cascade]
```
`Command::DropColumn` gains a `cascade: bool`. `--cascade` is
the first destructive cascade flag in the DSL; future
cascading drops should follow the same opt-in `--` pattern.
Indexes that do *not* cover the dropped column are recreated
normally regardless of the flag.
### Display — structure view
`render_structure` (the table-structure view in the output
panel) gains an `Indexes:` section, rendered after the
relationship sections and only when the table has at least one
user index:
```
Customers
Id [serial PK]
Email [text]
Indexes:
Customers_email_idx (Email)
cust_lookup (Email, Name)
```
`add index` and `drop index` return the affected table's
description, so the auto-show pattern (ADR-0014) displays the
updated structure — including this section — after the
command, the same as `add column` and `add relationship`.
### Display — items list (S2)
The items list (left panel) becomes a nested list: each table,
with its indexes indented beneath it.
```
Tables
Customers
Customers_email_idx
cust_lookup
Orders
Orders_date_idx
```
This satisfies `S2` ("the items list shows tables and
per-table indexes; designed to extend to additional element
kinds … without restructuring") — the nested model *is* that
extensible structure; future kinds (relationships, views) slot
in as further child rows.
The panel's data model changes from a flat `Vec<String>` of
table names to a structured list (table name plus its index
names), populated by a schema refresh that now also reads
indexes. Index rows are display-only: the current-table
highlight behaviour is unchanged, and selecting an index row
carries no new action in this ADR.
### Errors and edge cases
All user-facing strings obey the ADR-0002 rule — "the
database" / "the engine", never the engine product name.
- `add index` on a non-existent table → friendly error.
- `add index` naming a column the table does not have →
friendly error naming the column.
- Duplicate explicit index name, or an auto-name collision
(same table + column set) → friendly error naming the
existing index.
- `drop index <name>` for an unknown name → friendly error.
- `drop index on T(cols)` with no match → friendly error;
with multiple matches → ambiguity error listing candidates.
- Internal `__rdbms_*` tables are not user tables, so the
table identifier never resolves to one.
- `add index` / `drop index` are DSL DDL commands, available
in simple mode, appended to `history.log`, and replayable —
consistent with `add column` / `add relationship`.
### Out of scope
Explicitly excluded from this ADR:
- **UNIQUE indexes** (`add unique index`). A unique index is
also a constraint; UNIQUE is tracked as its own `C3`
sub-item and is a distinct teaching concern.
- **Partial indexes** (`CREATE INDEX ... WHERE`), **expression
/ computed indexes**, and per-column **`DESC` / collation**
modifiers — advanced features beyond the playground's
pedagogical aim. Plain column-list indexes only.
- **`EXPLAIN QUERY PLAN` / `QA1`** — the deliberate follow-up.
It needs its own rendering ADR (`QA2`) and builds on the
indexes this ADR delivers.
## Consequences
- The playground gains real, persistent indexes, advancing the
index portion of `C3` and satisfying `S2`.
- The rebuild-table primitive now preserves indexes. This also
closes a latent bug: once indexes exist, column rename /
type-change would otherwise silently drop them — there are no
indexes today, so the bug is latent rather than live, but the
fix ships with the feature that would trigger it.
- A new structural index representation threads through
`db.rs`, `persistence`, and `output_render`.
- No new internal table — a deliberate divergence from the
ADR-0013 relationship precedent, justified by SQLite owning
the index namespace natively.
- The items panel is no longer a flat list; the nested model
is the `S2`-mandated extension point for future element
kinds.
- `drop column --cascade` establishes the opt-in `--` flag
pattern for destructive cascades.
- `EXPLAIN QUERY PLAN` (`QA1`) becomes worthwhile: once it
lands, `show data <T> where <col> = <val>` is a query whose
plan visibly changes when an index on `<col>` exists.
## Implementation notes
Two details settled differently from the sketch above, recorded
here so the decision text and the code agree:
- **`rename column` / `drop column` do not use the rebuild
primitive.** Both run native `ALTER TABLE` (the playground
targets SQLite 3.25+/3.35+). `ALTER TABLE … RENAME COLUMN`
already rewrites index definitions that reference the renamed
column, so rename needs no index code at all. `drop column`
detects covering indexes directly and either refuses or, with
`--cascade`, issues `DROP INDEX` before the column drop. Only
`change column` (and the relationship operations) go through
the rebuild primitive, and there the column set is unchanged,
so the captured indexes are recreated verbatim — no
column-rename map or dropped-column set is needed.
- **The items list keeps a flat `app.tables` plus a cache
map.** Rather than restructuring `app.tables` and the
`TablesRefreshed` event payload, per-table index names ride
in `SchemaCache::table_indexes`, populated by the existing
schema-cache refresh. The panel renders the ordered table
list with each table's indexes indented beneath — the
`S2` nested view — reading the two together.
## Amendment 1 — UNIQUE indexes in advanced mode (2026-05-25)
This ADR's *Out of scope* excluded UNIQUE indexes (`add unique index`)
on the grounds that a unique index conflates two concepts the playground
teaches separately — an index (a performance structure) and a UNIQUE
*constraint* (an integrity rule, tracked as its own `C3` sub-item). That
reasoning was written on 2026-05-16, when the **simple-mode DSL was the
only input surface**, and it still holds there: simple mode teaches the
two concepts separately, so `add unique index` stays deferred.
ADR-0035 (advanced-mode SQL DDL) introduced a second surface whose
explicit posture is to "trust the user like SQL does" (ADR-0035 §7). On
that surface `CREATE UNIQUE INDEX` is standard SQL a learner types
verbatim, and the concept-separation argument does not transfer — so
ADR-0035 §4 lists `CREATE [UNIQUE] INDEX` and **§4d supersedes this
ADR's exclusion for the advanced surface**. The constraint track this
ADR deferred *to* (ADR-0018 → ADR-0029 → ADR-0035 §4a.2) has since
shipped, so there is no remaining dependency.
Mechanically, the index model gains an `IndexSchema.unique` flag — an
additive, `#[serde(default)]` `project.yaml` field (`version` stays
`1`); the engine already reports uniqueness via `pragma_index_list`
(origin `c`), so **no `__rdbms_*` metadata table is added** (the §Storage
decision is unchanged — the divergence from the relationship precedent
stands). The rebuild primitive re-emits `CREATE UNIQUE INDEX`; the
structure view and items panel mark a unique index `[unique]`
(ADR-0035 §4d). The redundant-column-set guard keys on `(columns,
unique)` so a plain and a unique index over the same columns are not
mutual duplicates.
The amendment also hardened the shared `do_add_index` executor to refuse
an internal `__rdbms_*` table as "no such table" (consistent with the
app-wide opacity of internal tables) — closing a latent exposure on
*both* the simple `add index` and the new SQL `CREATE INDEX` surfaces,
which both reach `do_add_index`.
## See also
- ADR-0004 / ADR-0015 (project file format and storage runtime)
- ADR-0009 (DSL command syntax conventions)
- ADR-0012 (internal column metadata — and why indexes diverge
from that precedent)
- ADR-0013 (relationships, the rebuild-table primitive, and the
`as <name>` convention)
- ADR-0014 (auto-show after writes)
- ADR-0023 / ADR-0024 (the unified grammar tree the new
commands plug into)