Files
rdbms-playground/docs/adr/0025-indexes.md
T
claude@clouddev1 0dc159fd7e Indexes: add index / drop index, persistence, display (ADR-0025)
Implement ADR-0025 — indexes as a DSL DDL feature.

- Grammar: `add index [as <name>] on <T> (<cols>)`, `drop index
  <name>` / `drop index on <T> (<cols>)`, plus a `--cascade`
  flag on `drop column`.
- db.rs: index operations over the engine's native index
  catalog (no metadata table). The rebuild-table primitive now
  captures and recreates indexes, so `change column` and the
  relationship operations no longer silently drop them.
- `drop column` refuses an indexed column unless `--cascade`,
  which drops the covering indexes and reports each.
- Persistence: additive `indexes:` list in `project.yaml`
  (version unchanged); round-trips through rebuild/export/import.
- Display: an `Indexes:` section in the structure view and a
  nested tables/indexes items panel (S2).

Reconciles requirements.md (C3 index portion, S2 satisfied)
and CLAUDE.md. 1038 tests passing (+31), clippy clean.
2026-05-16 00:15:55 +00:00

13 KiB

ADR-0025: Indexes

Status

Accepted

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.

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)