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.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
# 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:
|
||||
|
||||
```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.
|
||||
|
||||
## 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)
|
||||
@@ -30,3 +30,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md)
|
||||
- [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024)
|
||||
- [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note)
|
||||
- [ADR-0025 — Indexes](0025-indexes.md) — **Accepted**, `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`)
|
||||
|
||||
+16
-10
@@ -26,12 +26,12 @@ repo is pushed).
|
||||
|
||||
## Test baseline
|
||||
|
||||
After ADR-0024 full implementation + the handoff-14 cleanup
|
||||
pass: **1006 passing, 0 failing, 1 ignored** (`cargo test` —
|
||||
the one ignored test is a long-standing `` ```ignore ``
|
||||
doc-test in `src/friendly/mod.rs`). Clippy clean with the
|
||||
nursery lint group enabled. (Earlier reference point, after
|
||||
B2/C2: 449 passing.)
|
||||
After ADR-0025 (indexes): **1037 passing, 0 failing, 1
|
||||
ignored** (`cargo test` — the one ignored test is a
|
||||
long-standing `` ```ignore `` doc-test in
|
||||
`src/friendly/mod.rs`). Clippy clean with the nursery lint
|
||||
group enabled. (Earlier reference points: 1006 after ADR-0024
|
||||
+ the handoff-14 cleanup; 449 after B2/C2.)
|
||||
|
||||
---
|
||||
|
||||
@@ -47,11 +47,12 @@ B2/C2: 449 passing.)
|
||||
|
||||
- [ ] **S1** Three-region layout: items list (left), output
|
||||
panel (right), input field (bottom).
|
||||
- [ ] **S2** Items list shows tables and per-table indexes;
|
||||
- [x] **S2** Items list shows tables and per-table indexes;
|
||||
designed to extend to additional element kinds (relations,
|
||||
views, etc.) without restructuring.
|
||||
*(Progress: tables are listed live from the database; indexes
|
||||
pending alongside C3 index support.)*
|
||||
*(ADR-0025: the items panel renders a nested list — each
|
||||
table with its index names indented beneath it. The nested
|
||||
model is the extension point for future element kinds.)*
|
||||
- [ ] **S3** Output panel renders a visualization of the
|
||||
currently selected item and supports multiple tabs.
|
||||
- [ ] **S4** Hint area below the input field; keyboard-toggleable
|
||||
@@ -130,7 +131,9 @@ B2/C2: 449 passing.)
|
||||
FK with `ON DELETE` / `ON UPDATE` actions done (ADR-0013) —
|
||||
declared via `add 1:n relationship`; symmetric outbound +
|
||||
inbound view in the structure renderer; type compatibility
|
||||
validated at declaration via `Type::fk_target_type()`. Index,
|
||||
validated at declaration via `Type::fk_target_type()`.
|
||||
Indexes done (ADR-0025) — `add index` / `drop index`,
|
||||
rebuild-preserving, persisted in `project.yaml`.
|
||||
`NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT` still pending.)*
|
||||
- [~] **C3a** Modify relationship: `modify relationship <name>
|
||||
[on delete <action>] [on update <action>]`. Users can achieve
|
||||
@@ -339,6 +342,9 @@ B2/C2: 449 passing.)
|
||||
- [ ] **QA1** `EXPLAIN QUERY PLAN` is run on demand for queries;
|
||||
output is rendered as an annotated tree highlighting full
|
||||
scans, index use, and join order.
|
||||
*(Unblocked by ADR-0025: indexes now exist, so a plan for
|
||||
`show data <T> where <col>=<val>` visibly changes with an
|
||||
index. Still needs the QA2 rendering ADR.)*
|
||||
- [~] **QA2** Plan rendering specifics (tree layout, annotation
|
||||
taxonomy, colour scheme) — design and ADR pending.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user