diff --git a/CLAUDE.md b/CLAUDE.md index d16475f..1b067f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,8 +186,9 @@ not yet implemented: - **Column drops/renames/type changes** (B2 / C2 partial): the rebuild-table primitive (ADR-0013) is in place; the grammar and dispatch are pending. -- **Indexes** (C3 partial): `add index`, `drop index`, then - `EXPLAIN QUERY PLAN` rendering for QA1. +- **Indexes**: `add index` / `drop index` done (ADR-0025). + `EXPLAIN QUERY PLAN` rendering for QA1 still pending (needs + its own QA2 rendering ADR). - **Modify relationship** (C3a): drop+add covers the use case today. - **m:n convenience** (C4): auto-generates a junction table diff --git a/docs/adr/0025-indexes.md b/docs/adr/0025-indexes.md new file mode 100644 index 0000000..1229430 --- /dev/null +++ b/docs/adr/0025-indexes.md @@ -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 ] on ([, ...]) + +drop index +drop index on
([, ...]) +``` + +- `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 ` is optional. The `as` keyword introduces the + name, matching `add 1:n relationship [as ]` (ADR-0013 + established `as` as the convention for optional names). +- `on
` 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 ` is omitted, the executor generates +`
_[_...]_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 ` — for users who named the index or know + the generated name. +- `drop index on
(...)` — 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(
)` — index name, uniqueness, and + `origin` (`c` = `CREATE INDEX`, `u` = UNIQUE constraint, + `pk` = primary key). +- `PRAGMA index_info()` — 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 from 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` 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 ` 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 where = ` is a query whose + plan visibly changes when an index on `` 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 ` convention) +- ADR-0014 (auto-show after writes) +- ADR-0023 / ADR-0024 (the unified grammar tree the new + commands plug into) diff --git a/docs/adr/README.md b/docs/adr/README.md index 10ab623..fa48402 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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`) diff --git a/docs/requirements.md b/docs/requirements.md index 843ca11..b10587b 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 [on delete ] [on update ]`. 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 where =` 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. diff --git a/src/app.rs b/src/app.rs index 50d7c62..1fa1a18 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use tracing::{trace, warn}; use crate::action::Action; use crate::db::{ AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult, - InsertResult, TableDescription, UpdateResult, + DropColumnResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::{Command, ParseError, parse_command}; use crate::event::AppEvent; @@ -341,6 +341,10 @@ impl App { self.handle_dsl_add_column_success(&command, result); Vec::new() } + AppEvent::DslDropColumnSucceeded { command, result } => { + self.handle_dsl_drop_column_success(&command, result); + Vec::new() + } AppEvent::DslFailed { command, error, @@ -1146,6 +1150,26 @@ impl App { self.current_table = Some(result.description); } + fn handle_dsl_drop_column_success( + &mut self, + command: &Command, + result: DropColumnResult, + ) { + self.note_ok_summary(command); + // ADR-0025: when `--cascade` removed covering indexes, + // name each one so the learner sees the side effect. + for index in &result.dropped_indexes { + self.note_system(crate::t!( + "ok.index_dropped_with_column", + index = index, + )); + } + for line in crate::output_render::render_structure(&result.description) { + self.note_system(line); + } + self.current_table = Some(result.description); + } + fn handle_dsl_change_column_success( &mut self, command: &Command, @@ -1250,7 +1274,7 @@ impl App { command: &Command, facts: crate::friendly::FailureContext, ) -> crate::friendly::TranslateContext { - use crate::dsl::{Command as C, RelationshipSelector}; + use crate::dsl::{Command as C, IndexSelector, RelationshipSelector}; use crate::friendly::{Operation, TranslateContext}; let (operation, fallback_table, fallback_column) = match command { C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None), @@ -1260,7 +1284,7 @@ impl App { Some(table.as_str()), Some(column.as_str()), ), - C::DropColumn { table, column } => ( + C::DropColumn { table, column, .. } => ( Operation::DropColumn, Some(table.as_str()), Some(column.as_str()), @@ -1292,6 +1316,13 @@ impl App { ), RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None), }, + C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None), + C::DropIndex { selector } => match selector { + IndexSelector::Columns { table, .. } => { + (Operation::DropIndex, Some(table.as_str()), None) + } + IndexSelector::Named { .. } => (Operation::DropIndex, None, None), + }, C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None), C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None), C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None), @@ -2023,6 +2054,7 @@ mod tests { }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), } } diff --git a/src/completion.rs b/src/completion.rs index 609afae..2d1dbae 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -40,11 +40,15 @@ pub struct SchemaCache { pub tables: Vec, pub columns: Vec, pub relationships: Vec, + pub indexes: Vec, /// Per-table column metadata with user-facing types /// (ADR-0024 §Phase D). Keyed by table name; lookup is /// case-insensitive in `columns_for_table` so the walker /// can resolve `Customers` regardless of how it was typed. pub table_columns: std::collections::HashMap>, + /// Per-table user index names (ADR-0025). Keyed by table + /// name; drives the nested tables/indexes items panel (S2). + pub table_indexes: std::collections::HashMap>, } /// One column's user-facing type info, scoped to a table @@ -65,6 +69,7 @@ impl SchemaCache { IdentSource::Tables => &self.tables, IdentSource::Columns => &self.columns, IdentSource::Relationships => &self.relationships, + IdentSource::Indexes => &self.indexes, IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[], } } @@ -816,16 +821,23 @@ mod tests { } #[test] - fn multi_candidate_position_offers_column_and_one_to_n() { + fn multi_candidate_position_offers_add_subcommands() { // After `add ` the parser expects `column` (for - // `add column ...`) and `1` (the opener for + // `add column ...`), `index` (for `add index ...`, + // ADR-0025), and `1` (the opener for // `add 1:n relationship ...`). The completion engine - // surfaces both: `column` straight from the keyword - // expected-set, and `1:n` as a composite literal - // candidate so the user can Tab through to the - // relationship form without knowing the surface syntax. + // sections keyword candidates (`column`, `index`) + // ahead of the `1:n` composite literal, so the literal + // sorts last even though `add 1:n` is declared second. let cs = cands("add ", 4); - assert_eq!(cs, vec!["column".to_string(), "1:n".to_string()]); + assert_eq!( + cs, + vec![ + "column".to_string(), + "index".to_string(), + "1:n".to_string(), + ] + ); } #[test] @@ -1039,7 +1051,10 @@ mod tests { } #[test] - fn drop_offers_three_alternatives_alphabetised() { + fn drop_offers_all_four_subcommands() { + // `drop` branches: column / relationship / table / index + // (ADR-0025). Candidates follow grammar declaration + // order, so `index` — added last — appears last. let cs = cands("drop ", 5); assert_eq!( cs, @@ -1047,6 +1062,7 @@ mod tests { "column".to_string(), "relationship".to_string(), "table".to_string(), + "index".to_string(), ], ); } @@ -1593,13 +1609,16 @@ mod tests { c.sort_by(|a, b| a.text.cmp(&b.text)); c } - // `add ` exposes `column` and `1:n` — alphabetic ranker - // flips them. + // `add ` exposes `column`, `1:n` and `index` — the + // alphabetic ranker reorders them. let cache = SchemaCache::default(); let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker) .expect("some completion"); let texts: Vec = comp.candidates.into_iter().map(|c| c.text).collect(); - assert_eq!(texts, vec!["1:n".to_string(), "column".to_string()]); + assert_eq!( + texts, + vec!["1:n".to_string(), "column".to_string(), "index".to_string()] + ); } #[test] diff --git a/src/db.rs b/src/db.rs index 584a938..832b1c4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -31,7 +31,7 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use crate::dsl::action::ReferentialAction; -use crate::dsl::command::{ChangeColumnMode, RelationshipSelector, RowFilter}; +use crate::dsl::command::{ChangeColumnMode, IndexSelector, RelationshipSelector, RowFilter}; use crate::dsl::ColumnSpec; use crate::dsl::shortid; use crate::dsl::types::Type; @@ -39,8 +39,8 @@ use crate::dsl::value::{Bound, Value, ValueError}; use crate::output_render::{Alignment, render_diagnostic_table}; use crate::type_change; use crate::persistence::{ - CellValue, ColumnSchema, Persistence, PersistenceError, RelationshipSchema, SchemaSnapshot, - TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, + CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema, + SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, }; use crate::project::{DATA_DIR, PROJECT_YAML}; @@ -64,6 +64,24 @@ pub struct TableDescription { /// Relationships where *this* table is the parent (some /// other table's column references one of ours). pub inbound_relationships: Vec, + /// User-created indexes on this table (ADR-0025). + pub indexes: Vec, +} + +/// One user-created index on a table (ADR-0025). +/// +/// Read live from the engine's native catalog +/// (`pragma_index_list` / `pragma_index_info`); the playground +/// keeps no separate index metadata table. Only indexes with +/// origin `c` (a `CREATE INDEX` statement) are surfaced — the +/// automatic indexes backing primary keys and UNIQUE +/// constraints are not user indexes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexInfo { + pub name: String, + /// Indexed columns, in index order. + pub columns: Vec, + pub unique: bool, } /// One end of a relationship as seen from the table being @@ -220,6 +238,18 @@ pub struct AddColumnResult { pub client_side_notes: Vec, } +/// Outcome of a successful `drop column …` (ADR-0025). +/// +/// `dropped_indexes` names any index removed by `--cascade` +/// because it covered the dropped column. Empty in the common +/// case (no covering index, or none to cascade); the runtime +/// renders one note line per entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DropColumnResult { + pub description: TableDescription, + pub dropped_indexes: Vec, +} + /// Outcome of a successful `change column …` (ADR-0017 §6). /// /// `description` is the post-rebuild table structure (used for @@ -397,8 +427,9 @@ enum Request { DropColumn { table: String, column: String, + cascade: bool, source: Option, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, RenameColumn { table: String, @@ -440,6 +471,20 @@ enum Request { source: Option, reply: oneshot::Sender, DbError>>, }, + /// Create an index on a table (ADR-0025). + AddIndex { + name: Option, + table: String, + columns: Vec, + source: Option, + reply: oneshot::Sender>, + }, + /// Drop an index by name or by table + column set (ADR-0025). + DropIndex { + selector: IndexSelector, + source: Option, + reply: oneshot::Sender>, + }, Insert { table: String, columns: Option>, @@ -607,12 +652,48 @@ impl Database { &self, table: String, column: String, + cascade: bool, source: Option, - ) -> Result { + ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::DropColumn { table, column, + cascade, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn add_index( + &self, + name: Option, + table: String, + columns: Vec, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::AddIndex { + name, + table, + columns, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn drop_index( + &self, + selector: IndexSelector, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::DropIndex { + selector, source, reply, }) @@ -1021,6 +1102,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req Request::DropColumn { table, column, + cascade, source, reply, } => { @@ -1030,6 +1112,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req source.as_deref(), &table, &column, + cascade, )); } Request::RenameColumn { @@ -1119,6 +1202,34 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req &selector, )); } + Request::AddIndex { + name, + table, + columns, + source, + reply, + } => { + let _ = reply.send(do_add_index( + conn, + persistence, + source.as_deref(), + name.as_deref(), + &table, + &columns, + )); + } + Request::DropIndex { + selector, + source, + reply, + } => { + let _ = reply.send(do_drop_index( + conn, + persistence, + source.as_deref(), + &selector, + )); + } Request::Insert { table, columns, @@ -1255,6 +1366,27 @@ fn do_list_names_for( } Ok(out) } + IdentSource::Indexes => { + // User indexes only: a `CREATE INDEX` statement + // leaves a non-null `sql`, whereas the automatic + // indexes backing PKs / UNIQUE constraints have a + // null `sql`. + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master \ + WHERE type = 'index' AND sql IS NOT NULL \ + ORDER BY name;", + ) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) + } IdentSource::NewName | IdentSource::Types | IdentSource::Free => Ok(Vec::new()), } } @@ -1426,11 +1558,22 @@ fn read_schema_snapshot(conn: &Connection) -> Result { } let relationships = read_all_relationships(conn)?; + let mut indexes: Vec = Vec::new(); + for name in &table_names { + for idx in read_table_indexes(conn, name)? { + indexes.push(IndexSchema { + name: idx.name, + table: name.clone(), + columns: idx.columns, + }); + } + } let created_at = read_project_created_at(conn)?; Ok(SchemaSnapshot { created_at, tables, relationships, + indexes, }) } @@ -1977,7 +2120,8 @@ fn do_drop_column( source: Option<&str>, table: &str, column: &str, -) -> Result { + cascade: bool, +) -> Result { let schema = read_schema(conn, table)?; let col_info = schema .columns @@ -2011,9 +2155,39 @@ fn do_drop_column( ))); } + // Indexes covering this column (ADR-0025). Without + // `--cascade` a covered column is refused; with it, the + // covering indexes are dropped alongside the column. + let covering: Vec = read_table_indexes(conn, table)? + .into_iter() + .filter(|i| i.columns.iter().any(|c| c == column)) + .collect(); + if !covering.is_empty() && !cascade { + let names = covering + .iter() + .map(|i| format!("`{}`", i.name)) + .collect::>() + .join(", "); + return Err(DbError::Unsupported(format!( + "cannot drop `{table}.{column}` while an index covers \ + it ({names}); drop the index first, or pass `--cascade` \ + to drop the covering indexes too." + ))); + } + let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; + // Drop covering indexes first — the engine refuses + // DROP COLUMN on an indexed column otherwise. `covering` + // is empty unless `--cascade` was given (the refusal above). + for index in &covering { + tx.execute_batch(&format!( + "DROP INDEX {ident};", + ident = quote_ident(&index.name) + )) + .map_err(DbError::from_rusqlite)?; + } let ddl = format!( "ALTER TABLE {tbl} DROP COLUMN {col};", tbl = quote_ident(table), @@ -2036,7 +2210,10 @@ fn do_drop_column( }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; - Ok(description) + Ok(DropColumnResult { + description, + dropped_indexes: covering.into_iter().map(|i| i.name).collect(), + }) } /// Rename a column. @@ -3104,6 +3281,68 @@ fn read_schema(conn: &Connection, table: &str) -> Result { }) } +/// Read the user-created indexes on `table` (ADR-0025). +/// +/// `pragma_index_list` reports every index; we keep only those +/// with origin `c` (a `CREATE INDEX` statement) and skip partial +/// indexes — the playground never creates partial indexes, and +/// surfacing the automatic PK / UNIQUE indexes as user indexes +/// would be misleading. Results are ordered by index name for +/// stable rendering. +fn read_table_indexes(conn: &Connection, table: &str) -> Result, DbError> { + let mut list_stmt = conn + .prepare( + "SELECT name, \"unique\", origin, partial \ + FROM pragma_index_list(?1) \ + ORDER BY name;", + ) + .map_err(DbError::from_rusqlite)?; + let metas = list_stmt + .query_map([table], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? != 0, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)? != 0, + )) + }) + .map_err(DbError::from_rusqlite)?; + let mut keep: Vec<(String, bool)> = Vec::new(); + for meta in metas { + let (name, unique, origin, partial) = meta.map_err(DbError::from_rusqlite)?; + if origin == "c" && !partial { + keep.push((name, unique)); + } + } + let mut out = Vec::with_capacity(keep.len()); + for (name, unique) in keep { + let columns = read_index_columns(conn, &name)?; + out.push(IndexInfo { + name, + columns, + unique, + }); + } + Ok(out) +} + +/// The indexed columns of `index`, in index order. +fn read_index_columns(conn: &Connection, index: &str) -> Result, DbError> { + let mut stmt = conn + .prepare( + "SELECT name FROM pragma_index_info(?1) ORDER BY seqno;", + ) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([index], |row| row.get::<_, String>(0)) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) +} + fn parse_action_from_sqlite(s: &str) -> ReferentialAction { // SQLite stores the action keywords in upper-case form // ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT"). @@ -3270,6 +3509,13 @@ where copy_data(&tx, &temp_name, table)?; + // Capture the table's user indexes before the drop — + // `DROP TABLE` discards them (ADR-0025). They are + // recreated verbatim after the rename: every caller of + // this primitive preserves the column set, so the index + // column references stay valid. + let captured_indexes = read_table_indexes(&tx, table)?; + tx.execute_batch(&format!( "DROP TABLE {ident};", ident = quote_ident(table) @@ -3282,6 +3528,22 @@ where )) .map_err(DbError::from_rusqlite)?; + for index in &captured_indexes { + let cols = index + .columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let unique_kw = if index.unique { "UNIQUE " } else { "" }; + tx.execute_batch(&format!( + "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});", + idx = quote_ident(&index.name), + tbl = quote_ident(table), + )) + .map_err(DbError::from_rusqlite)?; + } + metadata_updates(&tx)?; // Verify referential integrity before committing. Any @@ -3597,6 +3859,167 @@ fn do_drop_relationship( Ok(Some(do_describe_table(conn, &parent_table)?)) } +/// Create an index on `table` over `columns` (ADR-0025). +/// +/// Refuses a redundant index on an already-indexed column set +/// and a name collision. The index name is auto-generated as +/// `
__idx` when not supplied. +fn do_add_index( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + name: Option<&str>, + table: &str, + columns: &[String], +) -> Result { + // 1. Table must exist; gather its columns. + let schema = read_schema(conn, table)?; + // 2. Every indexed column must exist on the table. + for col in columns { + if !schema.columns.iter().any(|c| &c.name == col) { + return Err(DbError::Sqlite { + message: format!("no such column: {table}.{col}"), + kind: SqliteErrorKind::NoSuchColumn, + }); + } + } + // 3. Refuse a redundant index over an identical column set. + let existing = read_table_indexes(conn, table)?; + if let Some(dup) = existing + .iter() + .find(|i| i.columns.as_slice() == columns) + { + return Err(DbError::Unsupported(format!( + "the columns ({}) of `{table}` are already indexed by `{}`.", + columns.join(", "), + dup.name, + ))); + } + // 4. Resolve the index name (auto-generate when omitted). + let resolved = name.map_or_else( + || format!("{table}_{}_idx", columns.join("_")), + ToString::to_string, + ); + // 5. Refuse a name collision. + let name_taken: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'index' AND name = ?1;", + [&resolved], + |row| row.get(0), + ) + .map_err(DbError::from_rusqlite)?; + if name_taken > 0 { + return Err(DbError::Unsupported(format!( + "an index named `{resolved}` already exists. \ + Pick a different name or drop the existing one first." + ))); + } + // 6. Create the index and persist. + let tx = conn + .unchecked_transaction() + .map_err(DbError::from_rusqlite)?; + let cols_csv = columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let ddl = format!( + "CREATE INDEX {idx} ON {tbl} ({cols});", + idx = quote_ident(&resolved), + tbl = quote_ident(table), + cols = cols_csv, + ); + debug!(ddl = %ddl, "add_index"); + tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; + let description = do_describe_table(conn, table)?; + let changes = Changes { + schema_dirty: true, + ..Changes::default() + }; + finalize_persistence(conn, persistence, source, &changes)?; + tx.commit().map_err(DbError::from_rusqlite)?; + Ok(description) +} + +/// Drop an index identified by name or by table + column set +/// (ADR-0025). Returns the affected table's description. +fn do_drop_index( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + selector: &IndexSelector, +) -> Result { + let (index_name, table_name) = match selector { + IndexSelector::Named { name } => { + let lookup = conn.query_row( + "SELECT tbl_name FROM sqlite_master \ + WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;", + [name], + |row| row.get::<_, String>(0), + ); + match lookup { + Ok(table) => (name.clone(), table), + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Err(DbError::Sqlite { + message: format!("no such index: {name}"), + kind: SqliteErrorKind::Other, + }); + } + Err(e) => return Err(DbError::from_rusqlite(e)), + } + } + IndexSelector::Columns { table, columns } => { + // Surface a missing table as such, not as "no index". + read_schema(conn, table)?; + let matches: Vec = read_table_indexes(conn, table)? + .into_iter() + .filter(|i| i.columns.as_slice() == columns.as_slice()) + .collect(); + match matches.as_slice() { + [] => { + return Err(DbError::Sqlite { + message: format!( + "no index on {table} ({}) exists", + columns.join(", ") + ), + kind: SqliteErrorKind::Other, + }); + } + [one] => (one.name.clone(), table.clone()), + many => { + let names = many + .iter() + .map(|i| format!("`{}`", i.name)) + .collect::>() + .join(", "); + return Err(DbError::Unsupported(format!( + "more than one index on {table} ({}) matches \ + ({names}); drop it by name instead.", + columns.join(", ") + ))); + } + } + } + }; + let tx = conn + .unchecked_transaction() + .map_err(DbError::from_rusqlite)?; + tx.execute_batch(&format!( + "DROP INDEX {ident};", + ident = quote_ident(&index_name) + )) + .map_err(DbError::from_rusqlite)?; + let description = do_describe_table(conn, &table_name)?; + let changes = Changes { + schema_dirty: true, + ..Changes::default() + }; + finalize_persistence(conn, persistence, source, &changes)?; + tx.commit().map_err(DbError::from_rusqlite)?; + Ok(description) +} + /// Read-only wrapper around `do_describe_table` that runs an /// auxiliary `history.log` append for user-issued /// `show table` commands. @@ -3659,12 +4082,14 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result>() + .join(", "); + tx.execute_batch(&format!( + "CREATE INDEX {idx} ON {tbl} ({cols});", + idx = quote_ident(&index.name), + tbl = quote_ident(&index.table), + )) + .map_err(DbError::from_rusqlite)?; + } + // 6. Verify FK consistency before committing. { let mut check = tx @@ -5062,11 +5506,16 @@ mod tests { .await .unwrap(); - let desc = db - .drop_column("T".to_string(), "Score".to_string(), None) + let result = db + .drop_column("T".to_string(), "Score".to_string(), false, None) .await .unwrap(); - let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect(); + let names: Vec<_> = result + .description + .columns + .iter() + .map(|c| c.name.as_str()) + .collect(); assert_eq!(names, vec!["id"]); // Row data still accessible (id was preserved); the @@ -5081,7 +5530,7 @@ mod tests { let db = db(); make_id_table(&db, "T").await; let err = db - .drop_column("T".to_string(), "id".to_string(), None) + .drop_column("T".to_string(), "id".to_string(), false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); @@ -5118,7 +5567,7 @@ mod tests { .unwrap(); // Try to drop the FK column on the child side. let err = db - .drop_column("Orders".to_string(), "cust_id".to_string(), None) + .drop_column("Orders".to_string(), "cust_id".to_string(), false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); @@ -5130,7 +5579,7 @@ mod tests { let db = db(); make_id_table(&db, "T").await; let err = db - .drop_column("T".to_string(), "Ghost".to_string(), None) + .drop_column("T".to_string(), "Ghost".to_string(), false, None) .await .unwrap_err(); match err { @@ -5139,6 +5588,304 @@ mod tests { } } + // --- indexes (ADR-0025) ----------------------------------- + + /// A `serial`-PK table with one extra text `Email` column — + /// something indexable. + async fn make_indexable_table(db: &Database, name: &str) { + make_id_table(db, name).await; + db.add_column(name.to_string(), "Email".to_string(), Type::Text, None) + .await + .expect("add Email column"); + } + + #[tokio::test] + async fn add_index_appears_in_description() { + let db = db(); + make_indexable_table(&db, "Customers").await; + let desc = db + .add_index( + Some("idx_email".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .expect("add index"); + assert_eq!(desc.indexes.len(), 1); + assert_eq!(desc.indexes[0].name, "idx_email"); + assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]); + } + + #[tokio::test] + async fn add_index_auto_generates_name() { + let db = db(); + make_indexable_table(&db, "Customers").await; + let desc = db + .add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) + .await + .expect("add index"); + assert_eq!(desc.indexes[0].name, "Customers_Email_idx"); + } + + #[tokio::test] + async fn add_index_composite_auto_name_joins_columns() { + let db = db(); + make_id_table(&db, "Orders").await; + db.add_column("Orders".to_string(), "CustId".to_string(), Type::Int, None) + .await + .unwrap(); + db.add_column("Orders".to_string(), "Day".to_string(), Type::Date, None) + .await + .unwrap(); + let desc = db + .add_index( + None, + "Orders".to_string(), + vec!["CustId".to_string(), "Day".to_string()], + None, + ) + .await + .expect("add index"); + assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx"); + assert_eq!( + desc.indexes[0].columns, + vec!["CustId".to_string(), "Day".to_string()] + ); + } + + #[tokio::test] + async fn add_index_rejects_duplicate_name() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_column("Customers".to_string(), "Nick".to_string(), Type::Text, None) + .await + .unwrap(); + db.add_index( + Some("idx".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let err = db + .add_index( + Some("idx".to_string()), + "Customers".to_string(), + vec!["Nick".to_string()], + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); + } + + #[tokio::test] + async fn add_index_rejects_redundant_column_set() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_index( + Some("a".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let err = db + .add_index( + Some("b".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); + } + + #[tokio::test] + async fn add_index_rejects_missing_column() { + let db = db(); + make_indexable_table(&db, "Customers").await; + let err = db + .add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::Sqlite { + kind: SqliteErrorKind::NoSuchColumn, + .. + } + ), + "got {err:?}" + ); + } + + #[tokio::test] + async fn add_index_rejects_missing_table() { + let db = db(); + let err = db + .add_index(None, "Ghost".to_string(), vec!["x".to_string()], None) + .await + .unwrap_err(); + assert!( + matches!( + err, + DbError::Sqlite { + kind: SqliteErrorKind::NoSuchTable, + .. + } + ), + "got {err:?}" + ); + } + + #[tokio::test] + async fn drop_index_by_name_removes_it() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_index( + Some("idx_email".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let desc = db + .drop_index( + IndexSelector::Named { + name: "idx_email".to_string(), + }, + None, + ) + .await + .expect("drop index"); + assert!(desc.indexes.is_empty()); + } + + #[tokio::test] + async fn drop_index_by_columns_removes_it() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) + .await + .unwrap(); + let desc = db + .drop_index( + IndexSelector::Columns { + table: "Customers".to_string(), + columns: vec!["Email".to_string()], + }, + None, + ) + .await + .expect("drop index"); + assert!(desc.indexes.is_empty()); + } + + #[tokio::test] + async fn drop_index_unknown_name_errors() { + let db = db(); + make_indexable_table(&db, "Customers").await; + let err = db + .drop_index( + IndexSelector::Named { + name: "nope".to_string(), + }, + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}"); + } + + #[tokio::test] + async fn drop_column_refuses_indexed_column_without_cascade() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_index( + Some("idx_email".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let err = db + .drop_column("Customers".to_string(), "Email".to_string(), false, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); + assert!(format!("{err}").contains("idx_email"), "got {err}"); + } + + #[tokio::test] + async fn drop_column_cascade_drops_covering_index() { + let db = db(); + make_indexable_table(&db, "Customers").await; + db.add_index( + Some("idx_email".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let result = db + .drop_column("Customers".to_string(), "Email".to_string(), true, None) + .await + .expect("drop column --cascade"); + assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]); + assert!(result.description.indexes.is_empty()); + assert!( + result + .description + .columns + .iter() + .all(|c| c.name != "Email"), + ); + } + + #[tokio::test] + async fn rebuild_table_preserves_indexes() { + // `change column` rebuilds the table; an index on an + // unrelated column must survive the rebuild (ADR-0025). + let db = db(); + make_indexable_table(&db, "T").await; + db.add_column("T".to_string(), "Score".to_string(), Type::Int, None) + .await + .unwrap(); + db.add_index( + Some("idx_email".to_string()), + "T".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); + let result = db + .change_column_type( + "T".to_string(), + "Score".to_string(), + Type::Real, + ChangeColumnMode::Default, + None, + ) + .await + .expect("change column type"); + assert_eq!(result.description.indexes.len(), 1); + assert_eq!(result.description.indexes[0].name, "idx_email"); + assert_eq!( + result.description.indexes[0].columns, + vec!["Email".to_string()] + ); + } + #[tokio::test] async fn rename_column_updates_schema_and_metadata() { let db = db(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index e3a7217..6848b13 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -46,10 +46,14 @@ pub enum Command { }, /// Remove a column from a table. Refused if the column is /// part of the primary key or is involved in a declared - /// relationship — drop the relationship first. + /// relationship — drop the relationship first. Refused, too, + /// when an index covers the column, unless `cascade` is set + /// (the `--cascade` flag), in which case the covering + /// indexes are dropped alongside the column (ADR-0025). DropColumn { table: String, column: String, + cascade: bool, }, /// Rename a column. SQLite handles cascading renames in /// FK references on other tables; the executor mirrors @@ -96,6 +100,19 @@ pub enum Command { DropRelationship { selector: RelationshipSelector, }, + /// Create an index on one or more columns of a table + /// (ADR-0025). `name` is optional — when `None`, the + /// executor auto-generates `
__idx`. + AddIndex { + name: Option, + table: String, + columns: Vec, + }, + /// Drop an index by name, or by positional reference to its + /// table and exact column set (ADR-0025). + DropIndex { + selector: IndexSelector, + }, /// Re-display a table's structure in the output. Doesn't /// change schema; useful when the user wants to look at a /// table they aren't currently DDL'ing on. @@ -253,6 +270,26 @@ impl std::fmt::Display for RelationshipSelector { } } +/// How a `drop index` command identifies the index to remove +/// (ADR-0025). Both forms are accepted; the executor resolves to +/// a single index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IndexSelector { + Named { name: String }, + Columns { table: String, columns: Vec }, +} + +impl std::fmt::Display for IndexSelector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Named { name } => write!(f, "{name}"), + Self::Columns { table, columns } => { + write!(f, "on {table} ({})", columns.join(", ")) + } + } + } +} + impl Command { /// Short label for log output and result rendering. #[must_use] @@ -266,6 +303,8 @@ impl Command { Self::ChangeColumnType { .. } => "change column", Self::AddRelationship { .. } => "add relationship", Self::DropRelationship { .. } => "drop relationship", + Self::AddIndex { .. } => "add index", + Self::DropIndex { .. } => "drop index", Self::ShowTable { .. } => "show table", Self::Insert { .. } => "insert into", Self::Update { .. } => "update", @@ -318,6 +357,14 @@ impl Command { // is a sensible fallback for logging. RelationshipSelector::Named { name } => name, }, + Self::AddIndex { table, .. } => table, + Self::DropIndex { selector } => match selector { + IndexSelector::Columns { table, .. } => table, + // A named drop doesn't name the table until the + // executor resolves it; the index name is a + // sensible fallback for logging. + IndexSelector::Named { name } => name, + }, // Replay isn't tied to a single table; the path is // the most identifying thing for log output. Self::Replay { path } => path, diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index be56ae3..3800408 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -12,7 +12,9 @@ //! `parent_table` vs `child_table` for the endpoints clause). use crate::dsl::action::ReferentialAction; -use crate::dsl::command::{ChangeColumnMode, ColumnSpec, Command, RelationshipSelector}; +use crate::dsl::command::{ + ChangeColumnMode, ColumnSpec, Command, IndexSelector, RelationshipSelector, +}; use crate::dsl::grammar::{ CommandNode, HintMode, IdentSource, Node, ValidationError, Word, shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR}, @@ -109,6 +111,40 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { inner: &RELATIONSHIP_NAME_NEW_IDENT, }; +const INDEX_NAME_EXISTING: Node = Node::Ident { + source: IdentSource::Indexes, + role: "index_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, +}; + +const INDEX_NAME_NEW_IDENT: Node = Node::Ident { + source: IdentSource::NewName, + role: "index_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, +}; +const INDEX_NAME_NEW: Node = Node::Hinted { + mode: NEW_NAME_HINT, + inner: &INDEX_NAME_NEW_IDENT, +}; + +// The column list shared by `add index` / `drop index`: one or +// more existing column names, comma-separated, inside parens. +// `COLUMN_NAME` narrows to the `on
` table's columns +// because that ident carries `writes_table: true`. +const INDEX_COLUMN_LIST: Node = Node::Repeated { + inner: &COLUMN_NAME, + separator: Some(&Node::Punct(',')), + min: 1, +}; + // `[to]` and `[table]` connectives. const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to"))); const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from"))); @@ -129,6 +165,11 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); // drop_column — `drop column [from] [table] : ` // ================================================================= +// `--cascade` (ADR-0025): opt-in to dropping any index that +// covers the column alongside the column itself. Without it, a +// covered column is refused with a friendly error. +const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade")); + const DROP_COLUMN_NODES: &[Node] = &[ Node::Word(Word::keyword("column")), FROM_OPT, @@ -136,6 +177,7 @@ const DROP_COLUMN_NODES: &[Node] = &[ TABLE_NAME_EXISTING, Node::Punct(':'), COLUMN_NAME, + DROP_COLUMN_CASCADE_OPT, ]; const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES); @@ -213,10 +255,34 @@ const DROP_RELATIONSHIP_NODES: &[Node] = &[ const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES); // ================================================================= -// drop entry — `drop (table|column|relationship) ...` +// drop_index — `drop index ( | on (, …))` // ================================================================= -const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE]; +const DI_POSITIONAL_NODES: &[Node] = &[ + Node::Word(Word::keyword("on")), + TABLE_NAME_EXISTING, + Node::Punct('('), + INDEX_COLUMN_LIST, + Node::Punct(')'), +]; +const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES); + +// Positional form first — it opens with the `on` keyword, so a +// bare index name can't be mistaken for it (mirrors DR_SELECTOR). +const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING]; +const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES); + +const DROP_INDEX_NODES: &[Node] = &[ + Node::Word(Word::keyword("index")), + DI_SELECTOR, +]; +const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES); + +// ================================================================= +// drop entry — `drop (table|column|relationship|index) ...` +// ================================================================= + +const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX]; const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); // ================================================================= @@ -316,10 +382,31 @@ const ADD_RELATIONSHIP_NODES: &[Node] = &[ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES); // ================================================================= -// add entry — `add (column|1:n relationship) …` +// add_index — `add index [as ] on (, …)` // ================================================================= -const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP]; +const AI_AS_NAME_NODES: &[Node] = &[ + Node::Word(Word::keyword("as")), + INDEX_NAME_NEW, +]; +const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES)); + +const ADD_INDEX_NODES: &[Node] = &[ + Node::Word(Word::keyword("index")), + AI_AS_NAME_OPT, + Node::Word(Word::keyword("on")), + TABLE_NAME_EXISTING, + Node::Punct('('), + INDEX_COLUMN_LIST, + Node::Punct(')'), +]; +const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES); + +// ================================================================= +// add entry — `add (column|1:n relationship|index) …` +// ================================================================= + +const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX]; const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES); // ================================================================= @@ -402,6 +489,18 @@ fn require_ident(path: &MatchedPath, role: &'static str) -> Result Vec { + path.items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Ident { role: r } if *r == role => Some(i.text.clone()), + _ => None, + }) + .collect() +} + fn parse_action(words: &[&'static str]) -> ReferentialAction { // `set null`, `no action`, `cascade`, `restrict`. if words.contains(&"set") && words.contains(&"null") { @@ -435,7 +534,32 @@ fn build_drop(path: &MatchedPath) -> Result { Some("column") => Ok(Command::DropColumn { table: require_ident(path, "table_name")?, column: require_ident(path, "column_name")?, + cascade: path + .items + .iter() + .any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))), }), + Some("index") => { + // Positional form has `on` as the third Word. + let has_on = path + .items + .iter() + .any(|i| matches!(&i.kind, MatchedKind::Word("on"))); + if has_on { + Ok(Command::DropIndex { + selector: IndexSelector::Columns { + table: require_ident(path, "table_name")?, + columns: collect_idents(path, "column_name"), + }, + }) + } else { + Ok(Command::DropIndex { + selector: IndexSelector::Named { + name: require_ident(path, "index_name")?, + }, + }) + } + } Some("relationship") => { // Endpoints form has `from` as the third Word. let has_from = path @@ -495,6 +619,11 @@ fn build_add(path: &MatchedPath) -> Result { }) } Some("1") => build_add_relationship(path), + Some("index") => Ok(Command::AddIndex { + name: ident(path, "index_name").map(str::to_string), + table: require_ident(path, "table_name")?, + columns: collect_idents(path, "column_name"), + }), _ => Err(ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown add subcommand".to_string())], @@ -638,6 +767,7 @@ pub static DROP: CommandNode = CommandNode { "parse.usage.drop_table", "parse.usage.drop_column", "parse.usage.drop_relationship", + "parse.usage.drop_index", ],}; pub static ADD: CommandNode = CommandNode { @@ -645,7 +775,11 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), - usage_ids: &["parse.usage.add_column", "parse.usage.add_relationship"],}; + usage_ids: &[ + "parse.usage.add_column", + "parse.usage.add_relationship", + "parse.usage.add_index", + ],}; pub static RENAME: CommandNode = CommandNode { entry: Word::keyword("rename"), diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 902230b..28fe936 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -67,6 +67,8 @@ pub enum IdentSource { Columns, /// Existing relationship name. Relationships, + /// Existing index name. + Indexes, /// Closed set from `Type::all()` — surfaced by the walker's /// content validator on column-type slots; not user-listable /// from the schema. @@ -82,7 +84,10 @@ impl IdentSource { /// entities rather than user invention or a closed set). #[must_use] pub const fn completes_from_schema(self) -> bool { - matches!(self, Self::Tables | Self::Columns | Self::Relationships) + matches!( + self, + Self::Tables | Self::Columns | Self::Relationships | Self::Indexes + ) } /// Human-facing label used in parse-error wording @@ -97,6 +102,7 @@ impl IdentSource { Self::Tables => "table name", Self::Columns => "column name", Self::Relationships => "relationship name", + Self::Indexes => "index name", Self::Types => "type", } } @@ -113,6 +119,7 @@ impl IdentSource { "table name" => Some(Self::Tables), "column name" => Some(Self::Columns), "relationship name" => Some(Self::Relationships), + "index name" => Some(Self::Indexes), "type" => Some(Self::Types), _ => None, } diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index e16e62e..7f9c916 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -20,8 +20,8 @@ pub mod walker; pub use action::ReferentialAction; pub use command::{ - AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue, - RelationshipSelector, RowFilter, + AppCommand, ChangeColumnMode, ColumnSpec, Command, IndexSelector, MessagesValue, + ModeValue, RelationshipSelector, RowFilter, }; pub use parser::{ParseError, parse_command}; pub use types::Type; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index a2757e1..0ec1463 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -235,6 +235,7 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String { IdentSource::Tables => "table name".to_string(), IdentSource::Columns => "column name".to_string(), IdentSource::Relationships => "relationship name".to_string(), + IdentSource::Indexes => "index name".to_string(), IdentSource::Types => "type".to_string(), IdentSource::NewName | IdentSource::Free => "identifier".to_string(), }, @@ -316,7 +317,7 @@ mod tests { use super::*; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ - ChangeColumnMode, ColumnSpec, RelationshipSelector, RowFilter, + ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter, }; use crate::dsl::types::Type; use crate::dsl::value::Value; @@ -471,6 +472,7 @@ mod tests { Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), + cascade: false, } ); } @@ -482,6 +484,7 @@ mod tests { Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), + cascade: false, } ); assert_eq!( @@ -489,6 +492,7 @@ mod tests { Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), + cascade: false, } ); assert_eq!( @@ -496,6 +500,7 @@ mod tests { Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), + cascade: false, } ); } @@ -1156,6 +1161,81 @@ mod tests { ); } + // --- add index / drop index (ADR-0025) --- + + #[test] + fn add_index_named() { + assert_eq!( + ok("add index as idx_email on Customers (Email)"), + Command::AddIndex { + name: Some("idx_email".to_string()), + table: "Customers".to_string(), + columns: vec!["Email".to_string()], + } + ); + } + + #[test] + fn add_index_unnamed() { + assert_eq!( + ok("add index on Customers (Email)"), + Command::AddIndex { + name: None, + table: "Customers".to_string(), + columns: vec!["Email".to_string()], + } + ); + } + + #[test] + fn add_index_composite_columns() { + assert_eq!( + ok("add index on Orders (CustId, Date)"), + Command::AddIndex { + name: None, + table: "Orders".to_string(), + columns: vec!["CustId".to_string(), "Date".to_string()], + } + ); + } + + #[test] + fn drop_index_by_name() { + assert_eq!( + ok("drop index idx_email"), + Command::DropIndex { + selector: IndexSelector::Named { + name: "idx_email".to_string(), + }, + } + ); + } + + #[test] + fn drop_index_by_columns() { + assert_eq!( + ok("drop index on Customers (Email)"), + Command::DropIndex { + selector: IndexSelector::Columns { + table: "Customers".to_string(), + columns: vec!["Email".to_string()], + }, + } + ); + } + + #[test] + fn drop_column_cascade_flag() { + assert_eq!( + ok("drop column Customers: Email --cascade"), + Command::DropColumn { + table: "Customers".to_string(), + column: "Email".to_string(), + cascade: true, + } + ); + } + #[test] fn identifier_allows_underscores_and_digits_after_start() { assert_eq!( diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 38dd3aa..92203bf 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -884,6 +884,7 @@ mod tests { let want = Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), + cascade: false, }; assert_eq!(parse("drop column Customers: Email").unwrap(), want); assert_eq!(parse("drop column from Customers: Email").unwrap(), want); diff --git a/src/event.rs b/src/event.rs index 1be338c..28b2290 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,7 +9,7 @@ use crossterm::event::KeyEvent; use crate::db::{ AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, - InsertResult, TableDescription, UpdateResult, + DropColumnResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::Command; @@ -56,6 +56,13 @@ pub enum AppEvent { command: Command, result: AddColumnResult, }, + /// A `drop column …` succeeded. `result` carries the + /// post-drop description plus the names of any indexes + /// removed by `--cascade` (ADR-0025). + DslDropColumnSucceeded { + command: Command, + result: DropColumnResult, + }, /// A DSL command failed. `error` is the structured /// payload, `facts` is the runtime-built schema-resolved /// enrichment (parent tables, attempted values, diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index c04c14d..81ea222 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -198,11 +198,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // code, not the catalog, because spacing is alignment- // sensitive in the multi-entry case. ("parse.usage.add_column", &[]), + ("parse.usage.add_index", &[]), ("parse.usage.add_relationship", &[]), ("parse.usage.change_column", &[]), ("parse.usage.create_table", &[]), ("parse.usage.delete", &[]), ("parse.usage.drop_column", &[]), + ("parse.usage.drop_index", &[]), ("parse.usage.drop_relationship", &[]), ("parse.usage.drop_table", &[]), ("parse.usage.insert", &[]), @@ -394,6 +396,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["table", "column", "src_ty", "target_ty", "total"], ), // ---- DSL command success summaries (ADR-0019 §9 sweep) ---- + ("ok.index_dropped_with_column", &["index"]), ("ok.rows_deleted", &["count"]), ("ok.rows_inserted", &["count"]), ("ok.rows_updated", &["count"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 1edc0c3..e1708c6 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -251,13 +251,17 @@ help: create table with pk [:, ...] — create a table drop: |- drop table — remove a table - drop column [from] [table] : — remove a column + drop column [from] [table] : [--cascade] — remove a column + (--cascade also drops any index that covers the column) drop relationship — remove a relationship + drop index — remove an index + drop index on (, ...) — remove an index by its columns add: |- add column [to] [table] : () — add a column (for serial/shortid on a non-empty table: existing rows auto-filled) add 1:n relationship [as ] from

.

to .[on delete ] [on update ] [--create-fk] — declare a relationship + add index [as ] on (, ...) — create an index rename: |- rename column [in] [table] : to — rename a column change: |- @@ -412,12 +416,16 @@ parse: drop_relationship: |- drop relationship drop relationship from .to .+ drop_index: |- + drop index + drop index on
([, ...]) add_column: "add column [to] [table]
: ()" add_relationship: |- add 1:n relationship [as ] from .to .[on delete ] [on update ] [--create-fk] + add_index: "add index [as ] on
([, ...])" rename_column: "rename column [in] [table]
: to " change_column: |- change column [in] [table]
: () @@ -700,6 +708,9 @@ ok: rows_inserted: " {count} row(s) inserted" rows_updated: " {count} row(s) updated" rows_deleted: " {count} row(s) deleted" + # Shown beneath a `drop column --cascade` summary, once per + # index removed because it covered the dropped column. + index_dropped_with_column: " also dropped index `{index}` (it covered the column)" # ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ------------ client_side: diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index 7cd5be6..cc6c3bd 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -65,6 +65,8 @@ pub enum Operation { ChangeColumnType, AddRelationship, DropRelationship, + AddIndex, + DropIndex, Query, Rebuild, Replay, @@ -92,6 +94,8 @@ impl Operation { Self::ChangeColumnType => "change column", Self::AddRelationship => "add relationship", Self::DropRelationship => "drop relationship", + Self::AddIndex => "add index", + Self::DropIndex => "drop index", Self::Query => "query", Self::Rebuild => "rebuild", Self::Replay => "replay", diff --git a/src/output_render.rs b/src/output_render.rs index bde0c25..8c24c63 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -132,6 +132,19 @@ pub fn render_structure(desc: &TableDescription) -> Vec { } } + // Indexes section (ADR-0025), shown only when the table + // carries at least one user-created index. + if !desc.indexes.is_empty() { + out.push("Indexes:".to_string()); + for index in &desc.indexes { + out.push(format!( + " {} ({})", + index.name, + index.columns.join(", "), + )); + } + } + out } @@ -331,7 +344,7 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> #[cfg(test)] mod tests { use super::*; - use crate::db::{ColumnDescription, RelationshipEnd}; + use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd}; use crate::dsl::ReferentialAction; use insta::assert_snapshot; @@ -548,6 +561,7 @@ mod tests { ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), }; assert_snapshot!(render_structure(&desc).join("\n")); } @@ -566,6 +580,7 @@ mod tests { on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], + indexes: Vec::new(), }; let out = render_structure(&desc).join("\n"); assert!( @@ -590,6 +605,7 @@ mod tests { ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), }; let out = render_structure(&desc).join("\n"); // PK appears for id, NOT NULL for name, blank for nick. @@ -597,6 +613,40 @@ mod tests { assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}"); } + #[test] + fn render_structure_shows_indexes_section() { + let desc = TableDescription { + name: "Customers".to_string(), + columns: vec![ + col("id", Type::Serial, true, false), + col("Email", Type::Text, false, false), + ], + outbound_relationships: Vec::new(), + inbound_relationships: Vec::new(), + indexes: vec![IndexInfo { + name: "idx_email".to_string(), + columns: vec!["Email".to_string()], + unique: false, + }], + }; + let out = render_structure(&desc).join("\n"); + assert!(out.contains("Indexes:"), "got:\n{out}"); + assert!(out.contains("idx_email (Email)"), "got:\n{out}"); + } + + #[test] + fn render_structure_omits_indexes_section_when_none() { + let desc = TableDescription { + name: "T".to_string(), + columns: vec![col("id", Type::Serial, true, false)], + outbound_relationships: Vec::new(), + inbound_relationships: Vec::new(), + indexes: Vec::new(), + }; + let out = render_structure(&desc).join("\n"); + assert!(!out.contains("Indexes:"), "got:\n{out}"); + } + #[test] fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() { let mut desc = TableDescription { @@ -610,6 +660,7 @@ mod tests { }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), }; let out = render_structure(&desc).join("\n"); // The lowercase form of the SQLite type should appear. diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 3f04a35..8c03828 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -120,6 +120,11 @@ pub struct SchemaSnapshot { pub created_at: String, pub tables: Vec, pub relationships: Vec, + /// Indexes across all tables (ADR-0025). Carried as a flat + /// list mirroring `relationships`; each entry names its + /// table. Empty for project files written before indexes + /// existed — the YAML field is optional on read. + pub indexes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,6 +147,15 @@ pub struct ColumnSchema { pub unique: bool, } +/// One index as recorded in `project.yaml` (ADR-0025). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexSchema { + pub name: String, + pub table: String, + /// The indexed columns, in index order. + pub columns: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelationshipSchema { pub name: String, @@ -342,6 +356,7 @@ mod tests { created_at: "2026-05-07T14:30:12Z".to_string(), tables: vec![], relationships: vec![], + indexes: vec![], }; p.write_schema(&schema).unwrap(); let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap(); diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index a0b77ad..55db703 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -23,7 +23,7 @@ use serde::Deserialize; use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; -use super::{ColumnSchema, RelationshipSchema, SchemaSnapshot, TableSchema}; +use super::{ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableSchema}; /// Serialize a `SchemaSnapshot` to a `project.yaml` body. #[must_use] @@ -51,9 +51,31 @@ pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String { } } + if schema.indexes.is_empty() { + let _ = writeln!(out, "indexes: []"); + } else { + let _ = writeln!(out, "indexes:"); + for index in &schema.indexes { + write_index(&mut out, index); + } + } + out } +fn write_index(out: &mut String, index: &IndexSchema) { + let _ = writeln!(out, " - name: {}", quote_if_needed(&index.name)); + let _ = writeln!(out, " table: {}", quote_if_needed(&index.table)); + write!(out, " columns: [").unwrap(); + for (i, col) in index.columns.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str("e_if_needed(col)); + } + let _ = writeln!(out, "]"); +} + fn write_table(out: &mut String, table: &TableSchema) { let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name)); write!(out, " primary_key: [").unwrap(); @@ -215,10 +237,20 @@ pub(crate) fn parse_schema(body: &str) -> Result { on_update, }); } + let indexes: Vec = raw + .indexes + .into_iter() + .map(|i| IndexSchema { + name: i.name, + table: i.table, + columns: i.columns, + }) + .collect(); Ok(SchemaSnapshot { created_at: raw.project.created_at, tables, relationships, + indexes, }) } @@ -279,6 +311,10 @@ struct RawProject { tables: Vec, #[serde(default)] relationships: Vec, + /// Optional: project files written before ADR-0025 carry no + /// `indexes:` field and default to an empty list. + #[serde(default)] + indexes: Vec, } #[derive(Deserialize)] @@ -320,6 +356,13 @@ struct RawEndpoint { column: String, } +#[derive(Deserialize)] +struct RawIndex { + name: String, + table: String, + columns: Vec, +} + #[cfg(test)] mod tests { use super::*; @@ -355,6 +398,11 @@ mod tests { on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], + indexes: vec![IndexSchema { + name: "Orders_CustId_idx".to_string(), + table: "Orders".to_string(), + columns: vec!["CustId".to_string()], + }], } } @@ -374,6 +422,9 @@ mod tests { assert!(body.contains("child: { table: Orders, column: CustId }")); assert!(body.contains("on_delete: cascade")); assert!(body.contains("on_update: no_action")); + assert!(body.contains("- name: Orders_CustId_idx")); + assert!(body.contains("table: Orders")); + assert!(body.contains("columns: [CustId]")); } #[test] @@ -382,9 +433,11 @@ mod tests { created_at: "2026-05-07T14:30:12Z".to_string(), tables: vec![], relationships: vec![], + indexes: vec![], }); assert!(body.contains("tables: []")); assert!(body.contains("relationships: []")); + assert!(body.contains("indexes: []")); } #[test] @@ -401,6 +454,7 @@ mod tests { }], }], relationships: vec![], + indexes: vec![], }); assert!(body.contains("- name: \"true\"")); assert!(body.contains("{ name: \"yes\", type: bool }")); @@ -432,6 +486,9 @@ relationships: [] let parsed = parse_schema(body).expect("parse minimal"); assert_eq!(parsed.tables.len(), 0); assert_eq!(parsed.relationships.len(), 0); + // A project file with no `indexes:` field (written + // before ADR-0025) parses with an empty index list. + assert_eq!(parsed.indexes.len(), 0); assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z"); } @@ -496,6 +553,7 @@ relationships: ], }], relationships: vec![], + indexes: vec![], }); assert!(body.contains("primary_key: [a, b]")); } diff --git a/src/runtime.rs b/src/runtime.rs index 0a439eb..90ce016 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -30,7 +30,7 @@ use crate::app::App; use crate::cli::Args; use crate::db::{ AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult, - InsertResult, TableDescription, UpdateResult, + DropColumnResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::Command; use crate::event::AppEvent; @@ -863,6 +863,9 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac if let Ok(rels) = database.list_names_for(IdentSource::Relationships).await { cache.relationships = rels; } + if let Ok(indexes) = database.list_names_for(IdentSource::Indexes).await { + cache.indexes = indexes; + } // Phase D (ADR-0024 §Phase D): per-table column metadata // with user-facing types. The walker's // `DynamicSubgrammar(column_value_list)` reads this to @@ -872,6 +875,11 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac // walker falls back to the schemaless value-literal list. for name in cache.tables.clone() { if let Ok(desc) = database.describe_table(name.clone(), None).await { + // Per-table index names for the items panel (S2, + // ADR-0025). Captured before `desc.columns` is + // consumed below. + let index_names: Vec = + desc.indexes.iter().map(|i| i.name.clone()).collect(); let cols: Vec = desc .columns .into_iter() @@ -882,7 +890,8 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac }) }) .collect(); - cache.table_columns.insert(name, cols); + cache.table_columns.insert(name.clone(), cols); + cache.table_indexes.insert(name, index_names); } } cache @@ -1039,6 +1048,10 @@ fn spawn_dsl_dispatch( command: command.clone(), result, }, + Ok(CommandOutcome::DropColumn(result)) => AppEvent::DslDropColumnSucceeded { + command: command.clone(), + result, + }, Err(DbError::PersistenceFatal { operation, path, @@ -1367,6 +1380,7 @@ enum CommandOutcome { Delete(DeleteResult), ChangeColumn(ChangeColumnTypeResult), AddColumn(AddColumnResult), + DropColumn(DropColumnResult), } /// Spawn a task that reads a script file and dispatches each @@ -1576,10 +1590,14 @@ async fn execute_command_typed( .add_column(table, column, ty, src) .await .map(CommandOutcome::AddColumn), - Command::DropColumn { table, column } => database - .drop_column(table, column, src) + Command::DropColumn { + table, + column, + cascade, + } => database + .drop_column(table, column, cascade, src) .await - .map(|d| CommandOutcome::Schema(Some(d))), + .map(CommandOutcome::DropColumn), Command::RenameColumn { table, old, new } => database .rename_column(table, old, new, src) .await @@ -1620,6 +1638,18 @@ async fn execute_command_typed( .drop_relationship(selector, src) .await .map(CommandOutcome::Schema), + Command::AddIndex { + name, + table, + columns, + } => database + .add_index(name, table, columns, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), + Command::DropIndex { selector } => database + .drop_index(selector, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), Command::ShowTable { name } => database .describe_table(name, src) .await diff --git a/src/ui.rs b/src/ui.rs index 3093e31..a9f553b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -420,20 +420,27 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec .as_ref() .map(|t| t.name.as_str()) .unwrap_or_default(); - let lines: Vec> = app - .tables - .iter() - .map(|name| { - let style = if name == highlight { - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.fg) - }; - Line::from(Span::styled(name.as_str(), style)) - }) - .collect(); + // Nested tables / per-table indexes (S2, ADR-0025): each + // table line, with its index names indented beneath it. + let mut lines: Vec> = Vec::new(); + for name in &app.tables { + let style = if name == highlight { + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + lines.push(Line::from(Span::styled(name.as_str(), style))); + if let Some(indexes) = app.schema_cache.table_indexes.get(name) { + for index in indexes { + lines.push(Line::from(Span::styled( + format!(" {index}"), + Style::default().fg(theme.muted), + ))); + } + } + } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } @@ -1013,6 +1020,7 @@ mod tests { ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), }; app.current_table = Some(desc); // Mirror what the App writes when a DSL command succeeds. @@ -1041,4 +1049,21 @@ mod tests { let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("populated_with_table_dark", snapshot); } + + #[test] + fn items_panel_nests_indexes_under_their_table() { + // S2 (ADR-0025): the items panel renders each table + // with its index names indented beneath it. + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.schema_cache.table_indexes.insert( + "Customers".to_string(), + vec!["idx_email".to_string()], + ); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!(out.contains("Customers"), "table listed:\n{out}"); + assert!(out.contains("Orders"), "table listed:\n{out}"); + assert!(out.contains("idx_email"), "index nested in panel:\n{out}"); + } } diff --git a/tests/iteration3_rebuild.rs b/tests/iteration3_rebuild.rs index 4bc8a84..1f2ce6f 100644 --- a/tests/iteration3_rebuild.rs +++ b/tests/iteration3_rebuild.rs @@ -391,3 +391,70 @@ fn rebuild_preserves_created_at_from_yaml() { "yaml should preserve the edited created_at:\n{final_yaml}", ); } + +/// Indexes round-trip through `project.yaml` and a full rebuild +/// (ADR-0025): create an index, drop the `.db`, rebuild from +/// text, confirm the index is back. +#[test] +fn rebuild_restores_indexes() { + let data = tempdir(); + let project_path = { + let project = project::open_or_create(None, Some(data.path())).unwrap(); + let path = project.path().to_path_buf(); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(path.clone()), + ) + .unwrap(); + rt().block_on(async { + db.create_table( + "Customers".to_string(), + vec![ + ColumnSpec { name: "id".to_string(), ty: Type::Serial }, + ColumnSpec { name: "Email".to_string(), ty: Type::Text }, + ], + vec!["id".to_string()], + Some("create table Customers with pk id:serial".to_string()), + ) + .await + .unwrap(); + db.add_index( + Some("idx_email".to_string()), + "Customers".to_string(), + vec!["Email".to_string()], + Some("add index as idx_email on Customers (Email)".to_string()), + ) + .await + .unwrap(); + }); + drop(db); + drop(project); + path + }; + + // The index must be recorded in project.yaml — the `.db` is + // a derived artifact and gets discarded next. + let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap(); + assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}"); + + fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); + + let project = project::Project::open(&project_path).unwrap(); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + ) + .unwrap(); + rt().block_on(async { + db.rebuild_from_text(project.path().to_path_buf(), None) + .await + .expect("rebuild"); + }); + + let desc = rt() + .block_on(async { db.describe_table("Customers".to_string(), None).await }) + .expect("describe_table"); + assert_eq!(desc.indexes.len(), 1, "index should survive rebuild"); + assert_eq!(desc.indexes[0].name, "idx_email"); + assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]); +} diff --git a/tests/typing_surface/index_ops.rs b/tests/typing_surface/index_ops.rs new file mode 100644 index 0000000..4fbbf8e --- /dev/null +++ b/tests/typing_surface/index_ops.rs @@ -0,0 +1,78 @@ +//! Matrix coverage for `add index [as ] on (, …)` +//! and `drop index ( | on (, …))` (ADR-0025). + +use crate::typing_surface::*; +use rdbms_playground::input_render::InputState; + +#[test] +fn after_add_offers_index_branch() { + let schema = schema_multi_table(); + let a = assess_at_end("add ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["index"]); + crate::snap!("after_add_index_branch", a); +} + +#[test] +fn add_index_after_on_offers_table_names() { + let schema = schema_multi_table(); + let a = assess_at_end("add index on ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["Customers", "Orders"]); + crate::snap!("add_index_after_on", a); +} + +#[test] +fn add_index_open_paren_narrows_to_table_columns() { + let schema = schema_multi_table(); + let a = assess_at_end("add index on Orders (", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["OrderId", "CustId", "Total"]); + assert_no_candidate_named(&a, &["id", "Name"]); + crate::snap!("add_index_open_paren", a); +} + +#[test] +fn complete_add_index_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("add index on Orders (CustId)", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("AddIndex")); + crate::snap!("add_index_complete", a); +} + +#[test] +fn complete_add_index_named_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("add index as ord_cust on Orders (CustId)", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("AddIndex")); + crate::snap!("add_index_named_complete", a); +} + +#[test] +fn after_drop_offers_index_branch() { + let schema = schema_multi_table(); + let a = assess_at_end("drop ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["index"]); + crate::snap!("drop_index_branch", a); +} + +#[test] +fn complete_drop_index_by_name_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("drop index some_idx", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("DropIndex")); + crate::snap!("drop_index_named_complete", a); +} + +#[test] +fn complete_drop_index_by_columns_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("drop index on Orders (CustId)", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("DropIndex")); + crate::snap!("drop_index_columns_complete", a); +} diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 931396c..8703771 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -32,6 +32,7 @@ pub mod create_table; pub mod drop_column; pub mod drop_relationship; pub mod add_relationship; +pub mod index_ops; pub mod rename_change_column; pub mod app_commands; pub mod candidate_ordering; @@ -203,6 +204,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { ChangeColumnType { .. } => "ChangeColumnType".into(), AddRelationship { .. } => "AddRelationship".into(), DropRelationship { .. } => "DropRelationship".into(), + AddIndex { .. } => "AddIndex".into(), + DropIndex { .. } => "DropIndex".into(), ShowTable { .. } => "ShowTable".into(), Insert { .. } => "Insert".into(), Update { .. } => "Update".into(), diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap index 22d99af..798e76f 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__add_relationship__after_add_offers_relationship_branch@after_add.snap @@ -14,6 +14,10 @@ Assessment { text: "column", kind: Keyword, }, + Candidate { + text: "index", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, @@ -34,6 +38,10 @@ Assessment { text: "column", kind: Keyword, }, + Candidate { + text: "index", + kind: Keyword, + }, Candidate { text: "1:n", kind: Keyword, diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_after_on_offers_table_names@add_index_after_on.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_after_on_offers_table_names@add_index_after_on.snap new file mode 100644 index 0000000..86751f9 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_after_on_offers_table_names@add_index_after_on.snap @@ -0,0 +1,47 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"add index on \" cursor=13" +expression: "& a" +--- +Assessment { + input: "add index on ", + cursor: 13, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "Customers", + kind: Identifier, + }, + Candidate { + text: "Orders", + kind: Identifier, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 13, + 13, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "Customers", + kind: Identifier, + }, + Candidate { + text: "Orders", + kind: Identifier, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_open_paren_narrows_to_table_columns@add_index_open_paren.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_open_paren_narrows_to_table_columns@add_index_open_paren.snap new file mode 100644 index 0000000..b533daa --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__add_index_open_paren_narrows_to_table_columns@add_index_open_paren.snap @@ -0,0 +1,55 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"add index on Orders (\" cursor=21" +expression: "& a" +--- +Assessment { + input: "add index on Orders (", + cursor: 21, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "CustId", + kind: Identifier, + }, + Candidate { + text: "OrderId", + kind: Identifier, + }, + Candidate { + text: "Total", + kind: Identifier, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 21, + 21, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "CustId", + kind: Identifier, + }, + Candidate { + text: "OrderId", + kind: Identifier, + }, + Candidate { + text: "Total", + kind: Identifier, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap new file mode 100644 index 0000000..0d3630c --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_add_offers_index_branch@after_add_index_branch.snap @@ -0,0 +1,55 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"add \" cursor=4" +expression: "& a" +--- +Assessment { + input: "add ", + cursor: 4, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "column", + kind: Keyword, + }, + Candidate { + text: "index", + kind: Keyword, + }, + Candidate { + text: "1:n", + kind: Keyword, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 4, + 4, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "column", + kind: Keyword, + }, + Candidate { + text: "index", + kind: Keyword, + }, + Candidate { + text: "1:n", + kind: Keyword, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap new file mode 100644 index 0000000..9aeccf7 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__after_drop_offers_index_branch@drop_index_branch.snap @@ -0,0 +1,63 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"drop \" cursor=5" +expression: "& a" +--- +Assessment { + input: "drop ", + cursor: 5, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "column", + kind: Keyword, + }, + Candidate { + text: "relationship", + kind: Keyword, + }, + Candidate { + text: "table", + kind: Keyword, + }, + Candidate { + text: "index", + kind: Keyword, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 5, + 5, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "column", + kind: Keyword, + }, + Candidate { + text: "relationship", + kind: Keyword, + }, + Candidate { + text: "table", + kind: Keyword, + }, + Candidate { + text: "index", + kind: Keyword, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_named_parses@add_index_named_complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_named_parses@add_index_named_complete.snap new file mode 100644 index 0000000..a836594 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_named_parses@add_index_named_complete.snap @@ -0,0 +1,19 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"add index as ord_cust on Orders (CustId)\" cursor=40" +expression: "& a" +--- +Assessment { + input: "add index as ord_cust on Orders (CustId)", + cursor: 40, + state: Valid, + hint: Some( + Prose( + "Submit with Enter", + ), + ), + completion: None, + parse_result: Ok( + "AddIndex", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_parses@add_index_complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_parses@add_index_complete.snap new file mode 100644 index 0000000..4047417 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_add_index_parses@add_index_complete.snap @@ -0,0 +1,19 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"add index on Orders (CustId)\" cursor=28" +expression: "& a" +--- +Assessment { + input: "add index on Orders (CustId)", + cursor: 28, + state: Valid, + hint: Some( + Prose( + "Submit with Enter", + ), + ), + completion: None, + parse_result: Ok( + "AddIndex", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_columns_parses@drop_index_columns_complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_columns_parses@drop_index_columns_complete.snap new file mode 100644 index 0000000..c02e2c7 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_columns_parses@drop_index_columns_complete.snap @@ -0,0 +1,19 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"drop index on Orders (CustId)\" cursor=29" +expression: "& a" +--- +Assessment { + input: "drop index on Orders (CustId)", + cursor: 29, + state: Valid, + hint: Some( + Prose( + "Submit with Enter", + ), + ), + completion: None, + parse_result: Ok( + "DropIndex", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_name_parses@drop_index_named_complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_name_parses@drop_index_named_complete.snap new file mode 100644 index 0000000..e388479 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__index_ops__complete_drop_index_by_name_parses@drop_index_named_complete.snap @@ -0,0 +1,19 @@ +--- +source: tests/typing_surface/index_ops.rs +description: "input=\"drop index some_idx\" cursor=19" +expression: "& a" +--- +Assessment { + input: "drop index some_idx", + cursor: 19, + state: Valid, + hint: Some( + Prose( + "No such identifier: `some_idx`", + ), + ), + completion: None, + parse_result: Ok( + "DropIndex", + ), +} diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 3021522..4871904 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -257,6 +257,7 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription { .collect(), outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), + indexes: Vec::new(), } } @@ -422,6 +423,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], + indexes: Vec::new(), }; app.update(AppEvent::DslSucceeded { command: Command::AddRelationship { @@ -470,6 +472,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], + indexes: Vec::new(), }; app.update(AppEvent::DslSucceeded { command: Command::AddColumn {