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.
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:
- 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_masterandPRAGMA index_list/index_infocarry the name, table, column list, and uniqueness natively. There is nothing app-specific to store. DROP TABLEsilently 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.playground.dbis a derived artifact (ADR-0004 / ADR-0015). Indexes must round-trip throughproject.yamlor they vanish onrebuild,export, andimport.
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 indexis a third branch of the existingaddcommand, alongsideadd columnandadd 1:n relationship;drop indexis a new branch of the existingdropcommand.as <name>is optional. Theaskeyword introduces the name, matchingadd 1:n relationship [as <name>](ADR-0013 establishedasas the convention for optional names).on <Table>uses the keywordon— the SQL-natural word forCREATE INDEX ... ON table, and pedagogically aligned.- The column list is parenthesised and comma-separated, the
same shape as
create tableandinsert. 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_idxadd 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, andorigin(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:stays1. The field is additive and optional: theserde_ymlreader 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 howtables/relationshipsrender. SchemaSnapshotgains anindexesvector alongsidetablesandrelationships.rebuild_from_textrecreates each index (viaCREATE INDEX) after the tables are built.export/importcarry 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:
- Before the
DROP TABLE, capture the table's user indexes structurally (name + ordered columns) viaPRAGMA index_list/index_info, filtered toorigin = 'c'. - After the
ALTER TABLE ... RENAME, recreate them withCREATE 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
--cascadeflag ondrop columnopts 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 indexon a non-existent table → friendly error.add indexnaming 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 indexare DSL DDL commands, available in simple mode, appended tohistory.log, and replayable — consistent withadd 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 ownC3sub-item and is a distinct teaching concern. - Partial indexes (
CREATE INDEX ... WHERE), expression / computed indexes, and per-columnDESC/ 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
C3and satisfyingS2. - 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, andoutput_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 --cascadeestablishes 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 columndo not use the rebuild primitive. Both run nativeALTER TABLE(the playground targets SQLite 3.25+/3.35+).ALTER TABLE … RENAME COLUMNalready rewrites index definitions that reference the renamed column, so rename needs no index code at all.drop columndetects covering indexes directly and either refuses or, with--cascade, issuesDROP INDEXbefore the column drop. Onlychange 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.tablesplus a cache map. Rather than restructuringapp.tablesand theTablesRefreshedevent payload, per-table index names ride inSchemaCache::table_indexes, populated by the existing schema-cache refresh. The panel renders the ordered table list with each table's indexes indented beneath — theS2nested 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)