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

15 KiB

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:

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)