Indexes: add index / drop index, persistence, display (ADR-0025)
Implement ADR-0025 — indexes as a DSL DDL feature. - Grammar: `add index [as <name>] on <T> (<cols>)`, `drop index <name>` / `drop index on <T> (<cols>)`, plus a `--cascade` flag on `drop column`. - db.rs: index operations over the engine's native index catalog (no metadata table). The rebuild-table primitive now captures and recreates indexes, so `change column` and the relationship operations no longer silently drop them. - `drop column` refuses an indexed column unless `--cascade`, which drops the covering indexes and reports each. - Persistence: additive `indexes:` list in `project.yaml` (version unchanged); round-trips through rebuild/export/import. - Display: an `Indexes:` section in the structure view and a nested tables/indexes items panel (S2). Reconciles requirements.md (C3 index portion, S2 satisfied) and CLAUDE.md. 1038 tests passing (+31), clippy clean.
This commit is contained in:
@@ -186,8 +186,9 @@ not yet implemented:
|
|||||||
- **Column drops/renames/type changes** (B2 / C2 partial): the
|
- **Column drops/renames/type changes** (B2 / C2 partial): the
|
||||||
rebuild-table primitive (ADR-0013) is in place; the grammar
|
rebuild-table primitive (ADR-0013) is in place; the grammar
|
||||||
and dispatch are pending.
|
and dispatch are pending.
|
||||||
- **Indexes** (C3 partial): `add index`, `drop index`, then
|
- **Indexes**: `add index` / `drop index` done (ADR-0025).
|
||||||
`EXPLAIN QUERY PLAN` rendering for QA1.
|
`EXPLAIN QUERY PLAN` rendering for QA1 still pending (needs
|
||||||
|
its own QA2 rendering ADR).
|
||||||
- **Modify relationship** (C3a): drop+add covers the use case
|
- **Modify relationship** (C3a): drop+add covers the use case
|
||||||
today.
|
today.
|
||||||
- **m:n convenience** (C4): auto-generates a junction table
|
- **m:n convenience** (C4): auto-generates a junction table
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# ADR-0025: Indexes
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The requirements checklist (`C3`) commits to indexes as part
|
||||||
|
of the schema-constraint surface, and `S2` commits to the
|
||||||
|
items list showing "tables and per-table indexes". Neither is
|
||||||
|
implemented yet.
|
||||||
|
|
||||||
|
Indexes are the natural next teaching topic after relationships:
|
||||||
|
they are the structure that makes `EXPLAIN QUERY PLAN` (`QA1`)
|
||||||
|
pedagogically interesting — the plan for a filtered query
|
||||||
|
visibly changes from a full scan to an index search once an
|
||||||
|
index exists. `QA1` itself is a deliberate follow-up (it needs
|
||||||
|
its own rendering ADR and a query worth explaining); this ADR
|
||||||
|
sets it up by giving the playground real indexes.
|
||||||
|
|
||||||
|
Three design problems shape the decision:
|
||||||
|
|
||||||
|
1. **SQLite owns the index namespace.** Unlike foreign keys —
|
||||||
|
which have no name slot, the problem ADR-0013 solved with an
|
||||||
|
internal metadata table — an index in SQLite *is* a named
|
||||||
|
object. `sqlite_master` and `PRAGMA index_list` /
|
||||||
|
`index_info` carry the name, table, column list, and
|
||||||
|
uniqueness natively. There is nothing app-specific to store.
|
||||||
|
2. **`DROP TABLE` silently drops a table's indexes.** The
|
||||||
|
rebuild-table primitive (ADR-0013) — used by change-column-
|
||||||
|
type and every relationship operation — drops and recreates
|
||||||
|
the table. Once indexes exist, every such operation would
|
||||||
|
erase them unless the primitive is taught to preserve them.
|
||||||
|
3. **`playground.db` is a derived artifact** (ADR-0004 /
|
||||||
|
ADR-0015). Indexes must round-trip through `project.yaml`
|
||||||
|
or they vanish on `rebuild`, `export`, and `import`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Grammar
|
||||||
|
|
||||||
|
Indexes are declared and removed via DSL commands following
|
||||||
|
ADR-0009 (required clauses keyword-based; optional names
|
||||||
|
introduced by `as` per the ADR-0013 convention; `--` flags for
|
||||||
|
opt-ins):
|
||||||
|
|
||||||
|
```
|
||||||
|
add index [as <name>] on <Table> (<col>[, <col>...])
|
||||||
|
|
||||||
|
drop index <name>
|
||||||
|
drop index on <Table> (<col>[, <col>...])
|
||||||
|
```
|
||||||
|
|
||||||
|
- `add index` is a third branch of the existing `add`
|
||||||
|
command, alongside `add column` and `add 1:n relationship`;
|
||||||
|
`drop index` is a new branch of the existing `drop` command.
|
||||||
|
- `as <name>` is optional. The `as` keyword introduces the
|
||||||
|
name, matching `add 1:n relationship [as <name>]` (ADR-0013
|
||||||
|
established `as` as the convention for optional names).
|
||||||
|
- `on <Table>` uses the keyword `on` — the SQL-natural word
|
||||||
|
for `CREATE INDEX ... ON table`, and pedagogically aligned.
|
||||||
|
- The column list is parenthesised and comma-separated, the
|
||||||
|
same shape as `create table` and `insert`. One or more
|
||||||
|
columns; multiple columns produce a composite index in the
|
||||||
|
given order. An empty list `()` is a parse error.
|
||||||
|
- Column-list completion resolves against the named table,
|
||||||
|
reusing the dynamic-subgrammar mechanism that already drives
|
||||||
|
`insert into T (...)` column candidates.
|
||||||
|
|
||||||
|
`add unique index` is **not** part of this ADR — see
|
||||||
|
*Out of scope*.
|
||||||
|
|
||||||
|
### Auto-name format
|
||||||
|
|
||||||
|
When `as <name>` is omitted, the executor generates
|
||||||
|
`<Table>_<col1>[_<col2>...]_idx`, mirroring the descriptive,
|
||||||
|
subject-first style of ADR-0013's relationship auto-names.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `add index on Customers (email)` → `Customers_email_idx`
|
||||||
|
- `add index on Orders (CustId, Date)` → `Orders_CustId_Date_idx`
|
||||||
|
|
||||||
|
If the generated name is already taken — which happens exactly
|
||||||
|
when the same columns of the same table are already indexed —
|
||||||
|
the command is refused with a friendly error naming the
|
||||||
|
existing index (a second index on an identical column set is
|
||||||
|
redundant). A duplicate *explicit* name is likewise a friendly
|
||||||
|
error.
|
||||||
|
|
||||||
|
### Drop forms
|
||||||
|
|
||||||
|
`drop index` accepts two forms, mirroring `drop relationship`:
|
||||||
|
|
||||||
|
- `drop index <name>` — for users who named the index or know
|
||||||
|
the generated name.
|
||||||
|
- `drop index on <Table> (<col>...)` — the positional form,
|
||||||
|
resolved by matching the table and exact column set against
|
||||||
|
the table's indexes. No match is a friendly error; more than
|
||||||
|
one match is an ambiguity error listing the candidates and
|
||||||
|
advising the user to drop by name.
|
||||||
|
|
||||||
|
### Storage — no metadata table
|
||||||
|
|
||||||
|
Indexes do **not** get a `__rdbms_playground_indexes` table.
|
||||||
|
SQLite stores everything the application needs natively:
|
||||||
|
|
||||||
|
- `PRAGMA index_list(<table>)` — index name, uniqueness, and
|
||||||
|
`origin` (`c` = `CREATE INDEX`, `u` = UNIQUE constraint,
|
||||||
|
`pk` = primary key).
|
||||||
|
- `PRAGMA index_info(<index>)` — the ordered column list.
|
||||||
|
|
||||||
|
The application reads indexes through these pragmas. Only
|
||||||
|
`origin = 'c'` indexes are treated as user indexes; the
|
||||||
|
automatic indexes SQLite creates to back primary keys and
|
||||||
|
UNIQUE constraints are not surfaced as user indexes.
|
||||||
|
|
||||||
|
This is a deliberate divergence from the ADR-0013 relationship
|
||||||
|
precedent. Relationships needed a metadata table because SQL
|
||||||
|
foreign keys have no name slot; indexes have one, so the
|
||||||
|
divergence is justified — adding a metadata table would
|
||||||
|
duplicate state SQLite already owns and create a consistency
|
||||||
|
hazard.
|
||||||
|
|
||||||
|
The in-memory representation is a small structural value
|
||||||
|
(`name`, `table`, ordered `columns`) carried by `db.rs`,
|
||||||
|
`persistence`, and the renderer.
|
||||||
|
|
||||||
|
### `project.yaml` persistence
|
||||||
|
|
||||||
|
A top-level `indexes:` list is added to `project.yaml`,
|
||||||
|
mirroring `relationships:`. Each entry records the index name,
|
||||||
|
its table, and its ordered column list:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
indexes:
|
||||||
|
- name: Customers_email_idx
|
||||||
|
table: Customers
|
||||||
|
columns: [email]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version:` stays `1`. The field is additive and optional:
|
||||||
|
the `serde_yml` reader marks it `#[serde(default)]`, so
|
||||||
|
project files written before this change parse unchanged. No
|
||||||
|
migrator is required (the ADR-0015 §F3 framework stays
|
||||||
|
empty).
|
||||||
|
- The hand-rolled writer emits `indexes: []` when there are
|
||||||
|
none, consistent with how `tables`/`relationships` render.
|
||||||
|
- `SchemaSnapshot` gains an `indexes` vector alongside
|
||||||
|
`tables` and `relationships`.
|
||||||
|
- `rebuild_from_text` recreates each index (via
|
||||||
|
`CREATE INDEX`) after the tables are built. `export` /
|
||||||
|
`import` carry indexes because they operate on the text
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
### Rebuild-table interaction
|
||||||
|
|
||||||
|
The `rebuild_table` primitive (ADR-0013) is extended so it no
|
||||||
|
longer loses indexes:
|
||||||
|
|
||||||
|
1. **Before** the `DROP TABLE`, capture the table's user
|
||||||
|
indexes structurally (name + ordered columns) via
|
||||||
|
`PRAGMA index_list` / `index_info`, filtered to
|
||||||
|
`origin = 'c'`.
|
||||||
|
2. **After** the `ALTER TABLE ... RENAME`, recreate them with
|
||||||
|
`CREATE INDEX`.
|
||||||
|
|
||||||
|
Recreation is parameterised by an optional column-rename map
|
||||||
|
and a set of dropped columns, so the same primitive serves
|
||||||
|
every caller:
|
||||||
|
|
||||||
|
- **add / drop relationship**, **change column type** — the
|
||||||
|
column set is unchanged; indexes are recreated verbatim.
|
||||||
|
- **rename column** — an index referencing the old column name
|
||||||
|
is regenerated with the new name; the index keeps its own
|
||||||
|
name. No error.
|
||||||
|
- **drop column** — see below.
|
||||||
|
|
||||||
|
Because indexes are captured structurally (not as raw SQL
|
||||||
|
text), regeneration after a rename is a clean substitution
|
||||||
|
rather than SQL string-munging.
|
||||||
|
|
||||||
|
### Drop / rename column interaction
|
||||||
|
|
||||||
|
- **rename column** and **change column type** preserve any
|
||||||
|
covering index transparently, per the rebuild rules above.
|
||||||
|
- **drop column** is refused by default when an index covers
|
||||||
|
the dropped column. The error names the offending index(es)
|
||||||
|
and advises dropping them first. This matches the existing
|
||||||
|
conservative posture of `drop column`, which already refuses
|
||||||
|
primary-key and FK-involved columns.
|
||||||
|
- A new `--cascade` flag on `drop column` opts in to the
|
||||||
|
cascading behaviour: covering indexes are dropped
|
||||||
|
automatically and each is reported in the result note.
|
||||||
|
|
||||||
|
```
|
||||||
|
drop column <col> from table <Table> [--cascade]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Command::DropColumn` gains a `cascade: bool`. `--cascade` is
|
||||||
|
the first destructive cascade flag in the DSL; future
|
||||||
|
cascading drops should follow the same opt-in `--` pattern.
|
||||||
|
Indexes that do *not* cover the dropped column are recreated
|
||||||
|
normally regardless of the flag.
|
||||||
|
|
||||||
|
### Display — structure view
|
||||||
|
|
||||||
|
`render_structure` (the table-structure view in the output
|
||||||
|
panel) gains an `Indexes:` section, rendered after the
|
||||||
|
relationship sections and only when the table has at least one
|
||||||
|
user index:
|
||||||
|
|
||||||
|
```
|
||||||
|
Customers
|
||||||
|
Id [serial PK]
|
||||||
|
Email [text]
|
||||||
|
Indexes:
|
||||||
|
Customers_email_idx (Email)
|
||||||
|
cust_lookup (Email, Name)
|
||||||
|
```
|
||||||
|
|
||||||
|
`add index` and `drop index` return the affected table's
|
||||||
|
description, so the auto-show pattern (ADR-0014) displays the
|
||||||
|
updated structure — including this section — after the
|
||||||
|
command, the same as `add column` and `add relationship`.
|
||||||
|
|
||||||
|
### Display — items list (S2)
|
||||||
|
|
||||||
|
The items list (left panel) becomes a nested list: each table,
|
||||||
|
with its indexes indented beneath it.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tables
|
||||||
|
Customers
|
||||||
|
Customers_email_idx
|
||||||
|
cust_lookup
|
||||||
|
Orders
|
||||||
|
Orders_date_idx
|
||||||
|
```
|
||||||
|
|
||||||
|
This satisfies `S2` ("the items list shows tables and
|
||||||
|
per-table indexes; designed to extend to additional element
|
||||||
|
kinds … without restructuring") — the nested model *is* that
|
||||||
|
extensible structure; future kinds (relationships, views) slot
|
||||||
|
in as further child rows.
|
||||||
|
|
||||||
|
The panel's data model changes from a flat `Vec<String>` of
|
||||||
|
table names to a structured list (table name plus its index
|
||||||
|
names), populated by a schema refresh that now also reads
|
||||||
|
indexes. Index rows are display-only: the current-table
|
||||||
|
highlight behaviour is unchanged, and selecting an index row
|
||||||
|
carries no new action in this ADR.
|
||||||
|
|
||||||
|
### Errors and edge cases
|
||||||
|
|
||||||
|
All user-facing strings obey the ADR-0002 rule — "the
|
||||||
|
database" / "the engine", never the engine product name.
|
||||||
|
|
||||||
|
- `add index` on a non-existent table → friendly error.
|
||||||
|
- `add index` naming a column the table does not have →
|
||||||
|
friendly error naming the column.
|
||||||
|
- Duplicate explicit index name, or an auto-name collision
|
||||||
|
(same table + column set) → friendly error naming the
|
||||||
|
existing index.
|
||||||
|
- `drop index <name>` for an unknown name → friendly error.
|
||||||
|
- `drop index on T(cols)` with no match → friendly error;
|
||||||
|
with multiple matches → ambiguity error listing candidates.
|
||||||
|
- Internal `__rdbms_*` tables are not user tables, so the
|
||||||
|
table identifier never resolves to one.
|
||||||
|
- `add index` / `drop index` are DSL DDL commands, available
|
||||||
|
in simple mode, appended to `history.log`, and replayable —
|
||||||
|
consistent with `add column` / `add relationship`.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
Explicitly excluded from this ADR:
|
||||||
|
|
||||||
|
- **UNIQUE indexes** (`add unique index`). A unique index is
|
||||||
|
also a constraint; UNIQUE is tracked as its own `C3`
|
||||||
|
sub-item and is a distinct teaching concern.
|
||||||
|
- **Partial indexes** (`CREATE INDEX ... WHERE`), **expression
|
||||||
|
/ computed indexes**, and per-column **`DESC` / collation**
|
||||||
|
modifiers — advanced features beyond the playground's
|
||||||
|
pedagogical aim. Plain column-list indexes only.
|
||||||
|
- **`EXPLAIN QUERY PLAN` / `QA1`** — the deliberate follow-up.
|
||||||
|
It needs its own rendering ADR (`QA2`) and builds on the
|
||||||
|
indexes this ADR delivers.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The playground gains real, persistent indexes, advancing the
|
||||||
|
index portion of `C3` and satisfying `S2`.
|
||||||
|
- The rebuild-table primitive now preserves indexes. This also
|
||||||
|
closes a latent bug: once indexes exist, column rename /
|
||||||
|
type-change would otherwise silently drop them — there are no
|
||||||
|
indexes today, so the bug is latent rather than live, but the
|
||||||
|
fix ships with the feature that would trigger it.
|
||||||
|
- A new structural index representation threads through
|
||||||
|
`db.rs`, `persistence`, and `output_render`.
|
||||||
|
- No new internal table — a deliberate divergence from the
|
||||||
|
ADR-0013 relationship precedent, justified by SQLite owning
|
||||||
|
the index namespace natively.
|
||||||
|
- The items panel is no longer a flat list; the nested model
|
||||||
|
is the `S2`-mandated extension point for future element
|
||||||
|
kinds.
|
||||||
|
- `drop column --cascade` establishes the opt-in `--` flag
|
||||||
|
pattern for destructive cascades.
|
||||||
|
- `EXPLAIN QUERY PLAN` (`QA1`) becomes worthwhile: once it
|
||||||
|
lands, `show data <T> where <col> = <val>` is a query whose
|
||||||
|
plan visibly changes when an index on `<col>` exists.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
Two details settled differently from the sketch above, recorded
|
||||||
|
here so the decision text and the code agree:
|
||||||
|
|
||||||
|
- **`rename column` / `drop column` do not use the rebuild
|
||||||
|
primitive.** Both run native `ALTER TABLE` (the playground
|
||||||
|
targets SQLite 3.25+/3.35+). `ALTER TABLE … RENAME COLUMN`
|
||||||
|
already rewrites index definitions that reference the renamed
|
||||||
|
column, so rename needs no index code at all. `drop column`
|
||||||
|
detects covering indexes directly and either refuses or, with
|
||||||
|
`--cascade`, issues `DROP INDEX` before the column drop. Only
|
||||||
|
`change column` (and the relationship operations) go through
|
||||||
|
the rebuild primitive, and there the column set is unchanged,
|
||||||
|
so the captured indexes are recreated verbatim — no
|
||||||
|
column-rename map or dropped-column set is needed.
|
||||||
|
|
||||||
|
- **The items list keeps a flat `app.tables` plus a cache
|
||||||
|
map.** Rather than restructuring `app.tables` and the
|
||||||
|
`TablesRefreshed` event payload, per-table index names ride
|
||||||
|
in `SchemaCache::table_indexes`, populated by the existing
|
||||||
|
schema-cache refresh. The panel renders the ordered table
|
||||||
|
list with each table's indexes indented beneath — the
|
||||||
|
`S2` nested view — reading the two together.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- ADR-0004 / ADR-0015 (project file format and storage runtime)
|
||||||
|
- ADR-0009 (DSL command syntax conventions)
|
||||||
|
- ADR-0012 (internal column metadata — and why indexes diverge
|
||||||
|
from that precedent)
|
||||||
|
- ADR-0013 (relationships, the rebuild-table primitive, and the
|
||||||
|
`as <name>` convention)
|
||||||
|
- ADR-0014 (auto-show after writes)
|
||||||
|
- ADR-0023 / ADR-0024 (the unified grammar tree the new
|
||||||
|
commands plug into)
|
||||||
@@ -30,3 +30,4 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md)
|
- [ADR-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-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-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note)
|
||||||
|
- [ADR-0025 — Indexes](0025-indexes.md) — **Accepted**, `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`)
|
||||||
|
|||||||
+16
-10
@@ -26,12 +26,12 @@ repo is pushed).
|
|||||||
|
|
||||||
## Test baseline
|
## Test baseline
|
||||||
|
|
||||||
After ADR-0024 full implementation + the handoff-14 cleanup
|
After ADR-0025 (indexes): **1037 passing, 0 failing, 1
|
||||||
pass: **1006 passing, 0 failing, 1 ignored** (`cargo test` —
|
ignored** (`cargo test` — the one ignored test is a
|
||||||
the one ignored test is a long-standing `` ```ignore ``
|
long-standing `` ```ignore `` doc-test in
|
||||||
doc-test in `src/friendly/mod.rs`). Clippy clean with the
|
`src/friendly/mod.rs`). Clippy clean with the nursery lint
|
||||||
nursery lint group enabled. (Earlier reference point, after
|
group enabled. (Earlier reference points: 1006 after ADR-0024
|
||||||
B2/C2: 449 passing.)
|
+ the handoff-14 cleanup; 449 after B2/C2.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,11 +47,12 @@ B2/C2: 449 passing.)
|
|||||||
|
|
||||||
- [ ] **S1** Three-region layout: items list (left), output
|
- [ ] **S1** Three-region layout: items list (left), output
|
||||||
panel (right), input field (bottom).
|
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,
|
designed to extend to additional element kinds (relations,
|
||||||
views, etc.) without restructuring.
|
views, etc.) without restructuring.
|
||||||
*(Progress: tables are listed live from the database; indexes
|
*(ADR-0025: the items panel renders a nested list — each
|
||||||
pending alongside C3 index support.)*
|
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
|
- [ ] **S3** Output panel renders a visualization of the
|
||||||
currently selected item and supports multiple tabs.
|
currently selected item and supports multiple tabs.
|
||||||
- [ ] **S4** Hint area below the input field; keyboard-toggleable
|
- [ ] **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) —
|
FK with `ON DELETE` / `ON UPDATE` actions done (ADR-0013) —
|
||||||
declared via `add 1:n relationship`; symmetric outbound +
|
declared via `add 1:n relationship`; symmetric outbound +
|
||||||
inbound view in the structure renderer; type compatibility
|
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.)*
|
`NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT` still pending.)*
|
||||||
- [~] **C3a** Modify relationship: `modify relationship <name>
|
- [~] **C3a** Modify relationship: `modify relationship <name>
|
||||||
[on delete <action>] [on update <action>]`. Users can achieve
|
[on delete <action>] [on update <action>]`. Users can achieve
|
||||||
@@ -339,6 +342,9 @@ B2/C2: 449 passing.)
|
|||||||
- [ ] **QA1** `EXPLAIN QUERY PLAN` is run on demand for queries;
|
- [ ] **QA1** `EXPLAIN QUERY PLAN` is run on demand for queries;
|
||||||
output is rendered as an annotated tree highlighting full
|
output is rendered as an annotated tree highlighting full
|
||||||
scans, index use, and join order.
|
scans, index use, and join order.
|
||||||
|
*(Unblocked by ADR-0025: indexes now exist, so a plan for
|
||||||
|
`show data <T> where <col>=<val>` visibly changes with an
|
||||||
|
index. Still needs the QA2 rendering ADR.)*
|
||||||
- [~] **QA2** Plan rendering specifics (tree layout, annotation
|
- [~] **QA2** Plan rendering specifics (tree layout, annotation
|
||||||
taxonomy, colour scheme) — design and ADR pending.
|
taxonomy, colour scheme) — design and ADR pending.
|
||||||
|
|
||||||
|
|||||||
+35
-3
@@ -14,7 +14,7 @@ use tracing::{trace, warn};
|
|||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
|
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
|
||||||
InsertResult, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::{Command, ParseError, parse_command};
|
use crate::dsl::{Command, ParseError, parse_command};
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
@@ -341,6 +341,10 @@ impl App {
|
|||||||
self.handle_dsl_add_column_success(&command, result);
|
self.handle_dsl_add_column_success(&command, result);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslDropColumnSucceeded { command, result } => {
|
||||||
|
self.handle_dsl_drop_column_success(&command, result);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
AppEvent::DslFailed {
|
AppEvent::DslFailed {
|
||||||
command,
|
command,
|
||||||
error,
|
error,
|
||||||
@@ -1146,6 +1150,26 @@ impl App {
|
|||||||
self.current_table = Some(result.description);
|
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(
|
fn handle_dsl_change_column_success(
|
||||||
&mut self,
|
&mut self,
|
||||||
command: &Command,
|
command: &Command,
|
||||||
@@ -1250,7 +1274,7 @@ impl App {
|
|||||||
command: &Command,
|
command: &Command,
|
||||||
facts: crate::friendly::FailureContext,
|
facts: crate::friendly::FailureContext,
|
||||||
) -> crate::friendly::TranslateContext {
|
) -> crate::friendly::TranslateContext {
|
||||||
use crate::dsl::{Command as C, RelationshipSelector};
|
use crate::dsl::{Command as C, IndexSelector, RelationshipSelector};
|
||||||
use crate::friendly::{Operation, TranslateContext};
|
use crate::friendly::{Operation, TranslateContext};
|
||||||
let (operation, fallback_table, fallback_column) = match command {
|
let (operation, fallback_table, fallback_column) = match command {
|
||||||
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
|
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
|
||||||
@@ -1260,7 +1284,7 @@ impl App {
|
|||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
Some(column.as_str()),
|
Some(column.as_str()),
|
||||||
),
|
),
|
||||||
C::DropColumn { table, column } => (
|
C::DropColumn { table, column, .. } => (
|
||||||
Operation::DropColumn,
|
Operation::DropColumn,
|
||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
Some(column.as_str()),
|
Some(column.as_str()),
|
||||||
@@ -1292,6 +1316,13 @@ impl App {
|
|||||||
),
|
),
|
||||||
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
|
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::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
|
||||||
C::Update { table, .. } => (Operation::Update, 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),
|
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
|
||||||
@@ -2023,6 +2054,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_relationships: Vec::new(),
|
inbound_relationships: Vec::new(),
|
||||||
|
indexes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-11
@@ -40,11 +40,15 @@ pub struct SchemaCache {
|
|||||||
pub tables: Vec<String>,
|
pub tables: Vec<String>,
|
||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
pub relationships: Vec<String>,
|
pub relationships: Vec<String>,
|
||||||
|
pub indexes: Vec<String>,
|
||||||
/// Per-table column metadata with user-facing types
|
/// Per-table column metadata with user-facing types
|
||||||
/// (ADR-0024 §Phase D). Keyed by table name; lookup is
|
/// (ADR-0024 §Phase D). Keyed by table name; lookup is
|
||||||
/// case-insensitive in `columns_for_table` so the walker
|
/// case-insensitive in `columns_for_table` so the walker
|
||||||
/// can resolve `Customers` regardless of how it was typed.
|
/// can resolve `Customers` regardless of how it was typed.
|
||||||
pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
|
pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
|
||||||
|
/// 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<String, Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One column's user-facing type info, scoped to a table
|
/// One column's user-facing type info, scoped to a table
|
||||||
@@ -65,6 +69,7 @@ impl SchemaCache {
|
|||||||
IdentSource::Tables => &self.tables,
|
IdentSource::Tables => &self.tables,
|
||||||
IdentSource::Columns => &self.columns,
|
IdentSource::Columns => &self.columns,
|
||||||
IdentSource::Relationships => &self.relationships,
|
IdentSource::Relationships => &self.relationships,
|
||||||
|
IdentSource::Indexes => &self.indexes,
|
||||||
IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[],
|
IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,16 +821,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// 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
|
// `add 1:n relationship ...`). The completion engine
|
||||||
// surfaces both: `column` straight from the keyword
|
// sections keyword candidates (`column`, `index`)
|
||||||
// expected-set, and `1:n` as a composite literal
|
// ahead of the `1:n` composite literal, so the literal
|
||||||
// candidate so the user can Tab through to the
|
// sorts last even though `add 1:n` is declared second.
|
||||||
// relationship form without knowing the surface syntax.
|
|
||||||
let cs = cands("add ", 4);
|
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]
|
#[test]
|
||||||
@@ -1039,7 +1051,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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);
|
let cs = cands("drop ", 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cs,
|
cs,
|
||||||
@@ -1047,6 +1062,7 @@ mod tests {
|
|||||||
"column".to_string(),
|
"column".to_string(),
|
||||||
"relationship".to_string(),
|
"relationship".to_string(),
|
||||||
"table".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.sort_by(|a, b| a.text.cmp(&b.text));
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
// `add ` exposes `column` and `1:n` — alphabetic ranker
|
// `add ` exposes `column`, `1:n` and `index` — the
|
||||||
// flips them.
|
// alphabetic ranker reorders them.
|
||||||
let cache = SchemaCache::default();
|
let cache = SchemaCache::default();
|
||||||
let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker)
|
let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker)
|
||||||
.expect("some completion");
|
.expect("some completion");
|
||||||
let texts: Vec<String> = comp.candidates.into_iter().map(|c| c.text).collect();
|
let texts: Vec<String> = 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]
|
#[test]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use tokio::sync::{mpsc, oneshot};
|
|||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::dsl::action::ReferentialAction;
|
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::ColumnSpec;
|
||||||
use crate::dsl::shortid;
|
use crate::dsl::shortid;
|
||||||
use crate::dsl::types::Type;
|
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::output_render::{Alignment, render_diagnostic_table};
|
||||||
use crate::type_change;
|
use crate::type_change;
|
||||||
use crate::persistence::{
|
use crate::persistence::{
|
||||||
CellValue, ColumnSchema, Persistence, PersistenceError, RelationshipSchema, SchemaSnapshot,
|
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
|
||||||
TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
||||||
};
|
};
|
||||||
use crate::project::{DATA_DIR, PROJECT_YAML};
|
use crate::project::{DATA_DIR, PROJECT_YAML};
|
||||||
|
|
||||||
@@ -64,6 +64,24 @@ pub struct TableDescription {
|
|||||||
/// Relationships where *this* table is the parent (some
|
/// Relationships where *this* table is the parent (some
|
||||||
/// other table's column references one of ours).
|
/// other table's column references one of ours).
|
||||||
pub inbound_relationships: Vec<RelationshipEnd>,
|
pub inbound_relationships: Vec<RelationshipEnd>,
|
||||||
|
/// User-created indexes on this table (ADR-0025).
|
||||||
|
pub indexes: Vec<IndexInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub unique: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One end of a relationship as seen from the table being
|
/// One end of a relationship as seen from the table being
|
||||||
@@ -220,6 +238,18 @@ pub struct AddColumnResult {
|
|||||||
pub client_side_notes: Vec<String>,
|
pub client_side_notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Outcome of a successful `change column …` (ADR-0017 §6).
|
/// Outcome of a successful `change column …` (ADR-0017 §6).
|
||||||
///
|
///
|
||||||
/// `description` is the post-rebuild table structure (used for
|
/// `description` is the post-rebuild table structure (used for
|
||||||
@@ -397,8 +427,9 @@ enum Request {
|
|||||||
DropColumn {
|
DropColumn {
|
||||||
table: String,
|
table: String,
|
||||||
column: String,
|
column: String,
|
||||||
|
cascade: bool,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
reply: oneshot::Sender<Result<DropColumnResult, DbError>>,
|
||||||
},
|
},
|
||||||
RenameColumn {
|
RenameColumn {
|
||||||
table: String,
|
table: String,
|
||||||
@@ -440,6 +471,20 @@ enum Request {
|
|||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
|
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
|
||||||
},
|
},
|
||||||
|
/// Create an index on a table (ADR-0025).
|
||||||
|
AddIndex {
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||||
|
},
|
||||||
|
/// Drop an index by name or by table + column set (ADR-0025).
|
||||||
|
DropIndex {
|
||||||
|
selector: IndexSelector,
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||||
|
},
|
||||||
Insert {
|
Insert {
|
||||||
table: String,
|
table: String,
|
||||||
columns: Option<Vec<String>>,
|
columns: Option<Vec<String>>,
|
||||||
@@ -607,12 +652,48 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
table: String,
|
table: String,
|
||||||
column: String,
|
column: String,
|
||||||
|
cascade: bool,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
) -> Result<TableDescription, DbError> {
|
) -> Result<DropColumnResult, DbError> {
|
||||||
let (reply, recv) = oneshot::channel();
|
let (reply, recv) = oneshot::channel();
|
||||||
self.send(Request::DropColumn {
|
self.send(Request::DropColumn {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
|
cascade,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_index(
|
||||||
|
&self,
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Result<TableDescription, DbError> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<TableDescription, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::DropIndex {
|
||||||
|
selector,
|
||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
})
|
})
|
||||||
@@ -1021,6 +1102,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
Request::DropColumn {
|
Request::DropColumn {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
|
cascade,
|
||||||
source,
|
source,
|
||||||
reply,
|
reply,
|
||||||
} => {
|
} => {
|
||||||
@@ -1030,6 +1112,7 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
source.as_deref(),
|
source.as_deref(),
|
||||||
&table,
|
&table,
|
||||||
&column,
|
&column,
|
||||||
|
cascade,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Request::RenameColumn {
|
Request::RenameColumn {
|
||||||
@@ -1119,6 +1202,34 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
&selector,
|
&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 {
|
Request::Insert {
|
||||||
table,
|
table,
|
||||||
columns,
|
columns,
|
||||||
@@ -1255,6 +1366,27 @@ fn do_list_names_for(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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()),
|
IdentSource::NewName | IdentSource::Types | IdentSource::Free => Ok(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1426,11 +1558,22 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relationships = read_all_relationships(conn)?;
|
let relationships = read_all_relationships(conn)?;
|
||||||
|
let mut indexes: Vec<IndexSchema> = 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)?;
|
let created_at = read_project_created_at(conn)?;
|
||||||
Ok(SchemaSnapshot {
|
Ok(SchemaSnapshot {
|
||||||
created_at,
|
created_at,
|
||||||
tables,
|
tables,
|
||||||
relationships,
|
relationships,
|
||||||
|
indexes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1977,7 +2120,8 @@ fn do_drop_column(
|
|||||||
source: Option<&str>,
|
source: Option<&str>,
|
||||||
table: &str,
|
table: &str,
|
||||||
column: &str,
|
column: &str,
|
||||||
) -> Result<TableDescription, DbError> {
|
cascade: bool,
|
||||||
|
) -> Result<DropColumnResult, DbError> {
|
||||||
let schema = read_schema(conn, table)?;
|
let schema = read_schema(conn, table)?;
|
||||||
let col_info = schema
|
let col_info = schema
|
||||||
.columns
|
.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<IndexInfo> = 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::<Vec<_>>()
|
||||||
|
.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
|
let tx = conn
|
||||||
.unchecked_transaction()
|
.unchecked_transaction()
|
||||||
.map_err(DbError::from_rusqlite)?;
|
.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!(
|
let ddl = format!(
|
||||||
"ALTER TABLE {tbl} DROP COLUMN {col};",
|
"ALTER TABLE {tbl} DROP COLUMN {col};",
|
||||||
tbl = quote_ident(table),
|
tbl = quote_ident(table),
|
||||||
@@ -2036,7 +2210,10 @@ fn do_drop_column(
|
|||||||
};
|
};
|
||||||
finalize_persistence(conn, persistence, source, &changes)?;
|
finalize_persistence(conn, persistence, source, &changes)?;
|
||||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
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.
|
/// Rename a column.
|
||||||
@@ -3104,6 +3281,68 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<IndexInfo>, 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<Vec<String>, 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 {
|
fn parse_action_from_sqlite(s: &str) -> ReferentialAction {
|
||||||
// SQLite stores the action keywords in upper-case form
|
// SQLite stores the action keywords in upper-case form
|
||||||
// ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT").
|
// ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT").
|
||||||
@@ -3270,6 +3509,13 @@ where
|
|||||||
|
|
||||||
copy_data(&tx, &temp_name, table)?;
|
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!(
|
tx.execute_batch(&format!(
|
||||||
"DROP TABLE {ident};",
|
"DROP TABLE {ident};",
|
||||||
ident = quote_ident(table)
|
ident = quote_ident(table)
|
||||||
@@ -3282,6 +3528,22 @@ where
|
|||||||
))
|
))
|
||||||
.map_err(DbError::from_rusqlite)?;
|
.map_err(DbError::from_rusqlite)?;
|
||||||
|
|
||||||
|
for index in &captured_indexes {
|
||||||
|
let cols = index
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| quote_ident(c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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)?;
|
metadata_updates(&tx)?;
|
||||||
|
|
||||||
// Verify referential integrity before committing. Any
|
// Verify referential integrity before committing. Any
|
||||||
@@ -3597,6 +3859,167 @@ fn do_drop_relationship(
|
|||||||
Ok(Some(do_describe_table(conn, &parent_table)?))
|
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
|
||||||
|
/// `<table>_<col…>_idx` when not supplied.
|
||||||
|
fn do_add_index(
|
||||||
|
conn: &Connection,
|
||||||
|
persistence: Option<&Persistence>,
|
||||||
|
source: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
table: &str,
|
||||||
|
columns: &[String],
|
||||||
|
) -> Result<TableDescription, DbError> {
|
||||||
|
// 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::<Vec<_>>()
|
||||||
|
.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<TableDescription, DbError> {
|
||||||
|
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<IndexInfo> = 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::<Vec<_>>()
|
||||||
|
.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
|
/// Read-only wrapper around `do_describe_table` that runs an
|
||||||
/// auxiliary `history.log` append for user-issued
|
/// auxiliary `history.log` append for user-issued
|
||||||
/// `show table` commands.
|
/// `show table` commands.
|
||||||
@@ -3659,12 +4082,14 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription,
|
|||||||
|
|
||||||
let outbound_relationships = read_relationships_outbound(conn, name)?;
|
let outbound_relationships = read_relationships_outbound(conn, name)?;
|
||||||
let inbound_relationships = read_relationships_inbound(conn, name)?;
|
let inbound_relationships = read_relationships_inbound(conn, name)?;
|
||||||
|
let indexes = read_table_indexes(conn, name)?;
|
||||||
|
|
||||||
Ok(TableDescription {
|
Ok(TableDescription {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
columns,
|
columns,
|
||||||
outbound_relationships,
|
outbound_relationships,
|
||||||
inbound_relationships,
|
inbound_relationships,
|
||||||
|
indexes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4396,6 +4821,25 @@ fn do_rebuild_from_text(
|
|||||||
load_table_csv(&tx, table, &csv_path)?;
|
load_table_csv(&tx, table, &csv_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5b. Recreate indexes (ADR-0025). Done after the data
|
||||||
|
// load — the result is identical either way, and
|
||||||
|
// this keeps the structural steps (tables, FKs,
|
||||||
|
// data) ahead of the derived index objects.
|
||||||
|
for index in &snapshot.indexes {
|
||||||
|
let cols = index
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| quote_ident(c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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.
|
// 6. Verify FK consistency before committing.
|
||||||
{
|
{
|
||||||
let mut check = tx
|
let mut check = tx
|
||||||
@@ -5062,11 +5506,16 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let desc = db
|
let result = db
|
||||||
.drop_column("T".to_string(), "Score".to_string(), None)
|
.drop_column("T".to_string(), "Score".to_string(), false, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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"]);
|
assert_eq!(names, vec!["id"]);
|
||||||
|
|
||||||
// Row data still accessible (id was preserved); the
|
// Row data still accessible (id was preserved); the
|
||||||
@@ -5081,7 +5530,7 @@ mod tests {
|
|||||||
let db = db();
|
let db = db();
|
||||||
make_id_table(&db, "T").await;
|
make_id_table(&db, "T").await;
|
||||||
let err = db
|
let err = db
|
||||||
.drop_column("T".to_string(), "id".to_string(), None)
|
.drop_column("T".to_string(), "id".to_string(), false, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||||
@@ -5118,7 +5567,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
// Try to drop the FK column on the child side.
|
// Try to drop the FK column on the child side.
|
||||||
let err = db
|
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
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||||
@@ -5130,7 +5579,7 @@ mod tests {
|
|||||||
let db = db();
|
let db = db();
|
||||||
make_id_table(&db, "T").await;
|
make_id_table(&db, "T").await;
|
||||||
let err = db
|
let err = db
|
||||||
.drop_column("T".to_string(), "Ghost".to_string(), None)
|
.drop_column("T".to_string(), "Ghost".to_string(), false, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
match 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]
|
#[tokio::test]
|
||||||
async fn rename_column_updates_schema_and_metadata() {
|
async fn rename_column_updates_schema_and_metadata() {
|
||||||
let db = db();
|
let db = db();
|
||||||
|
|||||||
+48
-1
@@ -46,10 +46,14 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
/// Remove a column from a table. Refused if the column is
|
/// Remove a column from a table. Refused if the column is
|
||||||
/// part of the primary key or is involved in a declared
|
/// 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 {
|
DropColumn {
|
||||||
table: String,
|
table: String,
|
||||||
column: String,
|
column: String,
|
||||||
|
cascade: bool,
|
||||||
},
|
},
|
||||||
/// Rename a column. SQLite handles cascading renames in
|
/// Rename a column. SQLite handles cascading renames in
|
||||||
/// FK references on other tables; the executor mirrors
|
/// FK references on other tables; the executor mirrors
|
||||||
@@ -96,6 +100,19 @@ pub enum Command {
|
|||||||
DropRelationship {
|
DropRelationship {
|
||||||
selector: RelationshipSelector,
|
selector: RelationshipSelector,
|
||||||
},
|
},
|
||||||
|
/// Create an index on one or more columns of a table
|
||||||
|
/// (ADR-0025). `name` is optional — when `None`, the
|
||||||
|
/// executor auto-generates `<Table>_<col…>_idx`.
|
||||||
|
AddIndex {
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
},
|
||||||
|
/// 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
|
/// Re-display a table's structure in the output. Doesn't
|
||||||
/// change schema; useful when the user wants to look at a
|
/// change schema; useful when the user wants to look at a
|
||||||
/// table they aren't currently DDL'ing on.
|
/// 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<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Command {
|
||||||
/// Short label for log output and result rendering.
|
/// Short label for log output and result rendering.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -266,6 +303,8 @@ impl Command {
|
|||||||
Self::ChangeColumnType { .. } => "change column",
|
Self::ChangeColumnType { .. } => "change column",
|
||||||
Self::AddRelationship { .. } => "add relationship",
|
Self::AddRelationship { .. } => "add relationship",
|
||||||
Self::DropRelationship { .. } => "drop relationship",
|
Self::DropRelationship { .. } => "drop relationship",
|
||||||
|
Self::AddIndex { .. } => "add index",
|
||||||
|
Self::DropIndex { .. } => "drop index",
|
||||||
Self::ShowTable { .. } => "show table",
|
Self::ShowTable { .. } => "show table",
|
||||||
Self::Insert { .. } => "insert into",
|
Self::Insert { .. } => "insert into",
|
||||||
Self::Update { .. } => "update",
|
Self::Update { .. } => "update",
|
||||||
@@ -318,6 +357,14 @@ impl Command {
|
|||||||
// is a sensible fallback for logging.
|
// is a sensible fallback for logging.
|
||||||
RelationshipSelector::Named { name } => name,
|
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
|
// Replay isn't tied to a single table; the path is
|
||||||
// the most identifying thing for log output.
|
// the most identifying thing for log output.
|
||||||
Self::Replay { path } => path,
|
Self::Replay { path } => path,
|
||||||
|
|||||||
+140
-6
@@ -12,7 +12,9 @@
|
|||||||
//! `parent_table` vs `child_table` for the endpoints clause).
|
//! `parent_table` vs `child_table` for the endpoints clause).
|
||||||
|
|
||||||
use crate::dsl::action::ReferentialAction;
|
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::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
|
CommandNode, HintMode, IdentSource, Node, ValidationError, Word,
|
||||||
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
||||||
@@ -109,6 +111,40 @@ const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
|||||||
inner: &RELATIONSHIP_NAME_NEW_IDENT,
|
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>` 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.
|
// `[to]` and `[table]` connectives.
|
||||||
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
|
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
|
||||||
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
|
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] <T> : <col>`
|
// drop_column — `drop column [from] [table] <T> : <col>`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
|
// `--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] = &[
|
const DROP_COLUMN_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("column")),
|
Node::Word(Word::keyword("column")),
|
||||||
FROM_OPT,
|
FROM_OPT,
|
||||||
@@ -136,6 +177,7 @@ const DROP_COLUMN_NODES: &[Node] = &[
|
|||||||
TABLE_NAME_EXISTING,
|
TABLE_NAME_EXISTING,
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
COLUMN_NAME,
|
COLUMN_NAME,
|
||||||
|
DROP_COLUMN_CASCADE_OPT,
|
||||||
];
|
];
|
||||||
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);
|
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);
|
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// drop entry — `drop (table|column|relationship) ...`
|
// drop_index — `drop index (<name> | on <T> (<col>, …))`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
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);
|
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);
|
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// add entry — `add (column|1:n relationship) …`
|
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
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);
|
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -402,6 +489,18 @@ fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, Valid
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every ident whose role matches, in matched (left-to-right)
|
||||||
|
/// order. Used by the column-list commands.
|
||||||
|
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
|
||||||
|
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 {
|
fn parse_action(words: &[&'static str]) -> ReferentialAction {
|
||||||
// `set null`, `no action`, `cascade`, `restrict`.
|
// `set null`, `no action`, `cascade`, `restrict`.
|
||||||
if words.contains(&"set") && words.contains(&"null") {
|
if words.contains(&"set") && words.contains(&"null") {
|
||||||
@@ -435,7 +534,32 @@ fn build_drop(path: &MatchedPath) -> Result<Command, ValidationError> {
|
|||||||
Some("column") => Ok(Command::DropColumn {
|
Some("column") => Ok(Command::DropColumn {
|
||||||
table: require_ident(path, "table_name")?,
|
table: require_ident(path, "table_name")?,
|
||||||
column: require_ident(path, "column_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") => {
|
Some("relationship") => {
|
||||||
// Endpoints form has `from` as the third Word.
|
// Endpoints form has `from` as the third Word.
|
||||||
let has_from = path
|
let has_from = path
|
||||||
@@ -495,6 +619,11 @@ fn build_add(path: &MatchedPath) -> Result<Command, ValidationError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Some("1") => build_add_relationship(path),
|
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 {
|
_ => Err(ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown add subcommand".to_string())],
|
args: vec![("detail", "unknown add subcommand".to_string())],
|
||||||
@@ -638,6 +767,7 @@ pub static DROP: CommandNode = CommandNode {
|
|||||||
"parse.usage.drop_table",
|
"parse.usage.drop_table",
|
||||||
"parse.usage.drop_column",
|
"parse.usage.drop_column",
|
||||||
"parse.usage.drop_relationship",
|
"parse.usage.drop_relationship",
|
||||||
|
"parse.usage.drop_index",
|
||||||
],};
|
],};
|
||||||
|
|
||||||
pub static ADD: CommandNode = CommandNode {
|
pub static ADD: CommandNode = CommandNode {
|
||||||
@@ -645,7 +775,11 @@ pub static ADD: CommandNode = CommandNode {
|
|||||||
shape: ADD_SHAPE,
|
shape: ADD_SHAPE,
|
||||||
ast_builder: build_add,
|
ast_builder: build_add,
|
||||||
help_id: Some("ddl.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 {
|
pub static RENAME: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("rename"),
|
entry: Word::keyword("rename"),
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ pub enum IdentSource {
|
|||||||
Columns,
|
Columns,
|
||||||
/// Existing relationship name.
|
/// Existing relationship name.
|
||||||
Relationships,
|
Relationships,
|
||||||
|
/// Existing index name.
|
||||||
|
Indexes,
|
||||||
/// Closed set from `Type::all()` — surfaced by the walker's
|
/// Closed set from `Type::all()` — surfaced by the walker's
|
||||||
/// content validator on column-type slots; not user-listable
|
/// content validator on column-type slots; not user-listable
|
||||||
/// from the schema.
|
/// from the schema.
|
||||||
@@ -82,7 +84,10 @@ impl IdentSource {
|
|||||||
/// entities rather than user invention or a closed set).
|
/// entities rather than user invention or a closed set).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn completes_from_schema(self) -> bool {
|
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
|
/// Human-facing label used in parse-error wording
|
||||||
@@ -97,6 +102,7 @@ impl IdentSource {
|
|||||||
Self::Tables => "table name",
|
Self::Tables => "table name",
|
||||||
Self::Columns => "column name",
|
Self::Columns => "column name",
|
||||||
Self::Relationships => "relationship name",
|
Self::Relationships => "relationship name",
|
||||||
|
Self::Indexes => "index name",
|
||||||
Self::Types => "type",
|
Self::Types => "type",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +119,7 @@ impl IdentSource {
|
|||||||
"table name" => Some(Self::Tables),
|
"table name" => Some(Self::Tables),
|
||||||
"column name" => Some(Self::Columns),
|
"column name" => Some(Self::Columns),
|
||||||
"relationship name" => Some(Self::Relationships),
|
"relationship name" => Some(Self::Relationships),
|
||||||
|
"index name" => Some(Self::Indexes),
|
||||||
"type" => Some(Self::Types),
|
"type" => Some(Self::Types),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -20,8 +20,8 @@ pub mod walker;
|
|||||||
|
|
||||||
pub use action::ReferentialAction;
|
pub use action::ReferentialAction;
|
||||||
pub use command::{
|
pub use command::{
|
||||||
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
|
AppCommand, ChangeColumnMode, ColumnSpec, Command, IndexSelector, MessagesValue,
|
||||||
RelationshipSelector, RowFilter,
|
ModeValue, RelationshipSelector, RowFilter,
|
||||||
};
|
};
|
||||||
pub use parser::{ParseError, parse_command};
|
pub use parser::{ParseError, parse_command};
|
||||||
pub use types::Type;
|
pub use types::Type;
|
||||||
|
|||||||
+81
-1
@@ -235,6 +235,7 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
|
|||||||
IdentSource::Tables => "table name".to_string(),
|
IdentSource::Tables => "table name".to_string(),
|
||||||
IdentSource::Columns => "column name".to_string(),
|
IdentSource::Columns => "column name".to_string(),
|
||||||
IdentSource::Relationships => "relationship name".to_string(),
|
IdentSource::Relationships => "relationship name".to_string(),
|
||||||
|
IdentSource::Indexes => "index name".to_string(),
|
||||||
IdentSource::Types => "type".to_string(),
|
IdentSource::Types => "type".to_string(),
|
||||||
IdentSource::NewName | IdentSource::Free => "identifier".to_string(),
|
IdentSource::NewName | IdentSource::Free => "identifier".to_string(),
|
||||||
},
|
},
|
||||||
@@ -316,7 +317,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::dsl::action::ReferentialAction;
|
use crate::dsl::action::ReferentialAction;
|
||||||
use crate::dsl::command::{
|
use crate::dsl::command::{
|
||||||
ChangeColumnMode, ColumnSpec, RelationshipSelector, RowFilter,
|
ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter,
|
||||||
};
|
};
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
use crate::dsl::value::Value;
|
use crate::dsl::value::Value;
|
||||||
@@ -471,6 +472,7 @@ mod tests {
|
|||||||
Command::DropColumn {
|
Command::DropColumn {
|
||||||
table: "Customers".to_string(),
|
table: "Customers".to_string(),
|
||||||
column: "Email".to_string(),
|
column: "Email".to_string(),
|
||||||
|
cascade: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -482,6 +484,7 @@ mod tests {
|
|||||||
Command::DropColumn {
|
Command::DropColumn {
|
||||||
table: "Customers".to_string(),
|
table: "Customers".to_string(),
|
||||||
column: "Email".to_string(),
|
column: "Email".to_string(),
|
||||||
|
cascade: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -489,6 +492,7 @@ mod tests {
|
|||||||
Command::DropColumn {
|
Command::DropColumn {
|
||||||
table: "Customers".to_string(),
|
table: "Customers".to_string(),
|
||||||
column: "Email".to_string(),
|
column: "Email".to_string(),
|
||||||
|
cascade: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -496,6 +500,7 @@ mod tests {
|
|||||||
Command::DropColumn {
|
Command::DropColumn {
|
||||||
table: "Customers".to_string(),
|
table: "Customers".to_string(),
|
||||||
column: "Email".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]
|
#[test]
|
||||||
fn identifier_allows_underscores_and_digits_after_start() {
|
fn identifier_allows_underscores_and_digits_after_start() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -884,6 +884,7 @@ mod tests {
|
|||||||
let want = Command::DropColumn {
|
let want = Command::DropColumn {
|
||||||
table: "Customers".to_string(),
|
table: "Customers".to_string(),
|
||||||
column: "Email".to_string(),
|
column: "Email".to_string(),
|
||||||
|
cascade: false,
|
||||||
};
|
};
|
||||||
assert_eq!(parse("drop column Customers: Email").unwrap(), want);
|
assert_eq!(parse("drop column Customers: Email").unwrap(), want);
|
||||||
assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
|
assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
|
||||||
|
|||||||
+8
-1
@@ -9,7 +9,7 @@ use crossterm::event::KeyEvent;
|
|||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
||||||
InsertResult, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
|
||||||
@@ -56,6 +56,13 @@ pub enum AppEvent {
|
|||||||
command: Command,
|
command: Command,
|
||||||
result: AddColumnResult,
|
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
|
/// A DSL command failed. `error` is the structured
|
||||||
/// payload, `facts` is the runtime-built schema-resolved
|
/// payload, `facts` is the runtime-built schema-resolved
|
||||||
/// enrichment (parent tables, attempted values,
|
/// enrichment (parent tables, attempted values,
|
||||||
|
|||||||
@@ -198,11 +198,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// code, not the catalog, because spacing is alignment-
|
// code, not the catalog, because spacing is alignment-
|
||||||
// sensitive in the multi-entry case.
|
// sensitive in the multi-entry case.
|
||||||
("parse.usage.add_column", &[]),
|
("parse.usage.add_column", &[]),
|
||||||
|
("parse.usage.add_index", &[]),
|
||||||
("parse.usage.add_relationship", &[]),
|
("parse.usage.add_relationship", &[]),
|
||||||
("parse.usage.change_column", &[]),
|
("parse.usage.change_column", &[]),
|
||||||
("parse.usage.create_table", &[]),
|
("parse.usage.create_table", &[]),
|
||||||
("parse.usage.delete", &[]),
|
("parse.usage.delete", &[]),
|
||||||
("parse.usage.drop_column", &[]),
|
("parse.usage.drop_column", &[]),
|
||||||
|
("parse.usage.drop_index", &[]),
|
||||||
("parse.usage.drop_relationship", &[]),
|
("parse.usage.drop_relationship", &[]),
|
||||||
("parse.usage.drop_table", &[]),
|
("parse.usage.drop_table", &[]),
|
||||||
("parse.usage.insert", &[]),
|
("parse.usage.insert", &[]),
|
||||||
@@ -394,6 +396,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
&["table", "column", "src_ty", "target_ty", "total"],
|
&["table", "column", "src_ty", "target_ty", "total"],
|
||||||
),
|
),
|
||||||
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
|
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
|
||||||
|
("ok.index_dropped_with_column", &["index"]),
|
||||||
("ok.rows_deleted", &["count"]),
|
("ok.rows_deleted", &["count"]),
|
||||||
("ok.rows_inserted", &["count"]),
|
("ok.rows_inserted", &["count"]),
|
||||||
("ok.rows_updated", &["count"]),
|
("ok.rows_updated", &["count"]),
|
||||||
|
|||||||
@@ -251,13 +251,17 @@ help:
|
|||||||
create table <T> with pk [<col>:<type>, ...] — create a table
|
create table <T> with pk [<col>:<type>, ...] — create a table
|
||||||
drop: |-
|
drop: |-
|
||||||
drop table <T> — remove a table
|
drop table <T> — remove a table
|
||||||
drop column [from] [table] <T>: <col> — remove a column
|
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
||||||
|
(--cascade also drops any index that covers the column)
|
||||||
drop relationship <name> — remove a relationship
|
drop relationship <name> — remove a relationship
|
||||||
|
drop index <name> — remove an index
|
||||||
|
drop index on <T> (<col>, ...) — remove an index by its columns
|
||||||
add: |-
|
add: |-
|
||||||
add column [to] [table] <T>: <col> (<type>) — add a column
|
add column [to] [table] <T>: <col> (<type>) — add a column
|
||||||
(for serial/shortid on a non-empty table: existing rows auto-filled)
|
(for serial/shortid on a non-empty table: existing rows auto-filled)
|
||||||
add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
|
add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
|
||||||
[on delete <action>] [on update <action>] [--create-fk] — declare a relationship
|
[on delete <action>] [on update <action>] [--create-fk] — declare a relationship
|
||||||
|
add index [as <name>] on <T> (<col>, ...) — create an index
|
||||||
rename: |-
|
rename: |-
|
||||||
rename column [in] [table] <T>: <old> to <new> — rename a column
|
rename column [in] [table] <T>: <old> to <new> — rename a column
|
||||||
change: |-
|
change: |-
|
||||||
@@ -412,12 +416,16 @@ parse:
|
|||||||
drop_relationship: |-
|
drop_relationship: |-
|
||||||
drop relationship <Name>
|
drop relationship <Name>
|
||||||
drop relationship from <Parent>.<col> to <Child>.<col>
|
drop relationship from <Parent>.<col> to <Child>.<col>
|
||||||
|
drop_index: |-
|
||||||
|
drop index <Name>
|
||||||
|
drop index on <Table> (<col>[, ...])
|
||||||
add_column: "add column [to] [table] <Table>: <Name> (<Type>)"
|
add_column: "add column [to] [table] <Table>: <Name> (<Type>)"
|
||||||
add_relationship: |-
|
add_relationship: |-
|
||||||
add 1:n relationship [as <Name>]
|
add 1:n relationship [as <Name>]
|
||||||
from <Parent>.<col> to <Child>.<col>
|
from <Parent>.<col> to <Child>.<col>
|
||||||
[on delete <action>] [on update <action>]
|
[on delete <action>] [on update <action>]
|
||||||
[--create-fk]
|
[--create-fk]
|
||||||
|
add_index: "add index [as <Name>] on <Table> (<col>[, ...])"
|
||||||
rename_column: "rename column [in] [table] <Table>: <Old> to <New>"
|
rename_column: "rename column [in] [table] <Table>: <Old> to <New>"
|
||||||
change_column: |-
|
change_column: |-
|
||||||
change column [in] [table] <Table>: <Name> (<Type>)
|
change column [in] [table] <Table>: <Name> (<Type>)
|
||||||
@@ -700,6 +708,9 @@ ok:
|
|||||||
rows_inserted: " {count} row(s) inserted"
|
rows_inserted: " {count} row(s) inserted"
|
||||||
rows_updated: " {count} row(s) updated"
|
rows_updated: " {count} row(s) updated"
|
||||||
rows_deleted: " {count} row(s) deleted"
|
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 success notes (ADR-0017 §6, ADR-0018 §9) ------------
|
||||||
client_side:
|
client_side:
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ pub enum Operation {
|
|||||||
ChangeColumnType,
|
ChangeColumnType,
|
||||||
AddRelationship,
|
AddRelationship,
|
||||||
DropRelationship,
|
DropRelationship,
|
||||||
|
AddIndex,
|
||||||
|
DropIndex,
|
||||||
Query,
|
Query,
|
||||||
Rebuild,
|
Rebuild,
|
||||||
Replay,
|
Replay,
|
||||||
@@ -92,6 +94,8 @@ impl Operation {
|
|||||||
Self::ChangeColumnType => "change column",
|
Self::ChangeColumnType => "change column",
|
||||||
Self::AddRelationship => "add relationship",
|
Self::AddRelationship => "add relationship",
|
||||||
Self::DropRelationship => "drop relationship",
|
Self::DropRelationship => "drop relationship",
|
||||||
|
Self::AddIndex => "add index",
|
||||||
|
Self::DropIndex => "drop index",
|
||||||
Self::Query => "query",
|
Self::Query => "query",
|
||||||
Self::Rebuild => "rebuild",
|
Self::Rebuild => "rebuild",
|
||||||
Self::Replay => "replay",
|
Self::Replay => "replay",
|
||||||
|
|||||||
+52
-1
@@ -132,6 +132,19 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +344,7 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) ->
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::{ColumnDescription, RelationshipEnd};
|
use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
|
||||||
use crate::dsl::ReferentialAction;
|
use crate::dsl::ReferentialAction;
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
|
|
||||||
@@ -548,6 +561,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_relationships: Vec::new(),
|
inbound_relationships: Vec::new(),
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
assert_snapshot!(render_structure(&desc).join("\n"));
|
assert_snapshot!(render_structure(&desc).join("\n"));
|
||||||
}
|
}
|
||||||
@@ -566,6 +580,7 @@ mod tests {
|
|||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
let out = render_structure(&desc).join("\n");
|
let out = render_structure(&desc).join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -590,6 +605,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_relationships: Vec::new(),
|
inbound_relationships: Vec::new(),
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
let out = render_structure(&desc).join("\n");
|
let out = render_structure(&desc).join("\n");
|
||||||
// PK appears for id, NOT NULL for name, blank for nick.
|
// 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}");
|
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]
|
#[test]
|
||||||
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
||||||
let mut desc = TableDescription {
|
let mut desc = TableDescription {
|
||||||
@@ -610,6 +660,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_relationships: Vec::new(),
|
inbound_relationships: Vec::new(),
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
let out = render_structure(&desc).join("\n");
|
let out = render_structure(&desc).join("\n");
|
||||||
// The lowercase form of the SQLite type should appear.
|
// The lowercase form of the SQLite type should appear.
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ pub struct SchemaSnapshot {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub tables: Vec<TableSchema>,
|
pub tables: Vec<TableSchema>,
|
||||||
pub relationships: Vec<RelationshipSchema>,
|
pub relationships: Vec<RelationshipSchema>,
|
||||||
|
/// 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<IndexSchema>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -142,6 +147,15 @@ pub struct ColumnSchema {
|
|||||||
pub unique: bool,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RelationshipSchema {
|
pub struct RelationshipSchema {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -342,6 +356,7 @@ mod tests {
|
|||||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||||
tables: vec![],
|
tables: vec![],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
|
indexes: vec![],
|
||||||
};
|
};
|
||||||
p.write_schema(&schema).unwrap();
|
p.write_schema(&schema).unwrap();
|
||||||
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
|
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
|
||||||
|
|||||||
+59
-1
@@ -23,7 +23,7 @@ use serde::Deserialize;
|
|||||||
use crate::dsl::action::ReferentialAction;
|
use crate::dsl::action::ReferentialAction;
|
||||||
use crate::dsl::types::Type;
|
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.
|
/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
|
||||||
#[must_use]
|
#[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
|
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) {
|
fn write_table(out: &mut String, table: &TableSchema) {
|
||||||
let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name));
|
let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name));
|
||||||
write!(out, " primary_key: [").unwrap();
|
write!(out, " primary_key: [").unwrap();
|
||||||
@@ -215,10 +237,20 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
|||||||
on_update,
|
on_update,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let indexes: Vec<IndexSchema> = raw
|
||||||
|
.indexes
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| IndexSchema {
|
||||||
|
name: i.name,
|
||||||
|
table: i.table,
|
||||||
|
columns: i.columns,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Ok(SchemaSnapshot {
|
Ok(SchemaSnapshot {
|
||||||
created_at: raw.project.created_at,
|
created_at: raw.project.created_at,
|
||||||
tables,
|
tables,
|
||||||
relationships,
|
relationships,
|
||||||
|
indexes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +311,10 @@ struct RawProject {
|
|||||||
tables: Vec<RawTable>,
|
tables: Vec<RawTable>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
relationships: Vec<RawRelationship>,
|
relationships: Vec<RawRelationship>,
|
||||||
|
/// Optional: project files written before ADR-0025 carry no
|
||||||
|
/// `indexes:` field and default to an empty list.
|
||||||
|
#[serde(default)]
|
||||||
|
indexes: Vec<RawIndex>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -320,6 +356,13 @@ struct RawEndpoint {
|
|||||||
column: String,
|
column: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawIndex {
|
||||||
|
name: String,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -355,6 +398,11 @@ mod tests {
|
|||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
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("child: { table: Orders, column: CustId }"));
|
||||||
assert!(body.contains("on_delete: cascade"));
|
assert!(body.contains("on_delete: cascade"));
|
||||||
assert!(body.contains("on_update: no_action"));
|
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]
|
#[test]
|
||||||
@@ -382,9 +433,11 @@ mod tests {
|
|||||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||||
tables: vec![],
|
tables: vec![],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
|
indexes: vec![],
|
||||||
});
|
});
|
||||||
assert!(body.contains("tables: []"));
|
assert!(body.contains("tables: []"));
|
||||||
assert!(body.contains("relationships: []"));
|
assert!(body.contains("relationships: []"));
|
||||||
|
assert!(body.contains("indexes: []"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -401,6 +454,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
|
indexes: vec![],
|
||||||
});
|
});
|
||||||
assert!(body.contains("- name: \"true\""));
|
assert!(body.contains("- name: \"true\""));
|
||||||
assert!(body.contains("{ name: \"yes\", type: bool }"));
|
assert!(body.contains("{ name: \"yes\", type: bool }"));
|
||||||
@@ -432,6 +486,9 @@ relationships: []
|
|||||||
let parsed = parse_schema(body).expect("parse minimal");
|
let parsed = parse_schema(body).expect("parse minimal");
|
||||||
assert_eq!(parsed.tables.len(), 0);
|
assert_eq!(parsed.tables.len(), 0);
|
||||||
assert_eq!(parsed.relationships.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");
|
assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +553,7 @@ relationships:
|
|||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
relationships: vec![],
|
relationships: vec![],
|
||||||
|
indexes: vec![],
|
||||||
});
|
});
|
||||||
assert!(body.contains("primary_key: [a, b]"));
|
assert!(body.contains("primary_key: [a, b]"));
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-5
@@ -30,7 +30,7 @@ use crate::app::App;
|
|||||||
use crate::cli::Args;
|
use crate::cli::Args;
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult,
|
AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult,
|
||||||
InsertResult, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
use crate::event::AppEvent;
|
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 {
|
if let Ok(rels) = database.list_names_for(IdentSource::Relationships).await {
|
||||||
cache.relationships = rels;
|
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
|
// Phase D (ADR-0024 §Phase D): per-table column metadata
|
||||||
// with user-facing types. The walker's
|
// with user-facing types. The walker's
|
||||||
// `DynamicSubgrammar(column_value_list)` reads this to
|
// `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.
|
// walker falls back to the schemaless value-literal list.
|
||||||
for name in cache.tables.clone() {
|
for name in cache.tables.clone() {
|
||||||
if let Ok(desc) = database.describe_table(name.clone(), None).await {
|
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<String> =
|
||||||
|
desc.indexes.iter().map(|i| i.name.clone()).collect();
|
||||||
let cols: Vec<TableColumn> = desc
|
let cols: Vec<TableColumn> = desc
|
||||||
.columns
|
.columns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -882,7 +890,8 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
cache.table_columns.insert(name, cols);
|
cache.table_columns.insert(name.clone(), cols);
|
||||||
|
cache.table_indexes.insert(name, index_names);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache
|
cache
|
||||||
@@ -1039,6 +1048,10 @@ fn spawn_dsl_dispatch(
|
|||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
result,
|
result,
|
||||||
},
|
},
|
||||||
|
Ok(CommandOutcome::DropColumn(result)) => AppEvent::DslDropColumnSucceeded {
|
||||||
|
command: command.clone(),
|
||||||
|
result,
|
||||||
|
},
|
||||||
Err(DbError::PersistenceFatal {
|
Err(DbError::PersistenceFatal {
|
||||||
operation,
|
operation,
|
||||||
path,
|
path,
|
||||||
@@ -1367,6 +1380,7 @@ enum CommandOutcome {
|
|||||||
Delete(DeleteResult),
|
Delete(DeleteResult),
|
||||||
ChangeColumn(ChangeColumnTypeResult),
|
ChangeColumn(ChangeColumnTypeResult),
|
||||||
AddColumn(AddColumnResult),
|
AddColumn(AddColumnResult),
|
||||||
|
DropColumn(DropColumnResult),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a task that reads a script file and dispatches each
|
/// 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)
|
.add_column(table, column, ty, src)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::AddColumn),
|
.map(CommandOutcome::AddColumn),
|
||||||
Command::DropColumn { table, column } => database
|
Command::DropColumn {
|
||||||
.drop_column(table, column, src)
|
table,
|
||||||
|
column,
|
||||||
|
cascade,
|
||||||
|
} => database
|
||||||
|
.drop_column(table, column, cascade, src)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(CommandOutcome::DropColumn),
|
||||||
Command::RenameColumn { table, old, new } => database
|
Command::RenameColumn { table, old, new } => database
|
||||||
.rename_column(table, old, new, src)
|
.rename_column(table, old, new, src)
|
||||||
.await
|
.await
|
||||||
@@ -1620,6 +1638,18 @@ async fn execute_command_typed(
|
|||||||
.drop_relationship(selector, src)
|
.drop_relationship(selector, src)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Schema),
|
.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
|
Command::ShowTable { name } => database
|
||||||
.describe_table(name, src)
|
.describe_table(name, src)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -420,20 +420,27 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| t.name.as_str())
|
.map(|t| t.name.as_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let lines: Vec<Line<'_>> = app
|
// Nested tables / per-table indexes (S2, ADR-0025): each
|
||||||
.tables
|
// table line, with its index names indented beneath it.
|
||||||
.iter()
|
let mut lines: Vec<Line<'_>> = Vec::new();
|
||||||
.map(|name| {
|
for name in &app.tables {
|
||||||
let style = if name == highlight {
|
let style = if name == highlight {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.fg)
|
.fg(theme.fg)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(theme.fg)
|
Style::default().fg(theme.fg)
|
||||||
};
|
};
|
||||||
Line::from(Span::styled(name.as_str(), style))
|
lines.push(Line::from(Span::styled(name.as_str(), style)));
|
||||||
})
|
if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
|
||||||
.collect();
|
for index in indexes {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {index}"),
|
||||||
|
Style::default().fg(theme.muted),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1020,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_relationships: Vec::new(),
|
inbound_relationships: Vec::new(),
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
app.current_table = Some(desc);
|
app.current_table = Some(desc);
|
||||||
// Mirror what the App writes when a DSL command succeeds.
|
// 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);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,3 +391,70 @@ fn rebuild_preserves_created_at_from_yaml() {
|
|||||||
"yaml should preserve the edited created_at:\n{final_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()]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Matrix coverage for `add index [as <name>] on <T> (<col>, …)`
|
||||||
|
//! and `drop index (<name> | on <T> (<col>, …))` (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);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ pub mod create_table;
|
|||||||
pub mod drop_column;
|
pub mod drop_column;
|
||||||
pub mod drop_relationship;
|
pub mod drop_relationship;
|
||||||
pub mod add_relationship;
|
pub mod add_relationship;
|
||||||
|
pub mod index_ops;
|
||||||
pub mod rename_change_column;
|
pub mod rename_change_column;
|
||||||
pub mod app_commands;
|
pub mod app_commands;
|
||||||
pub mod candidate_ordering;
|
pub mod candidate_ordering;
|
||||||
@@ -203,6 +204,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
||||||
AddRelationship { .. } => "AddRelationship".into(),
|
AddRelationship { .. } => "AddRelationship".into(),
|
||||||
DropRelationship { .. } => "DropRelationship".into(),
|
DropRelationship { .. } => "DropRelationship".into(),
|
||||||
|
AddIndex { .. } => "AddIndex".into(),
|
||||||
|
DropIndex { .. } => "DropIndex".into(),
|
||||||
ShowTable { .. } => "ShowTable".into(),
|
ShowTable { .. } => "ShowTable".into(),
|
||||||
Insert { .. } => "Insert".into(),
|
Insert { .. } => "Insert".into(),
|
||||||
Update { .. } => "Update".into(),
|
Update { .. } => "Update".into(),
|
||||||
|
|||||||
+8
@@ -14,6 +14,10 @@ Assessment {
|
|||||||
text: "column",
|
text: "column",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "index",
|
||||||
|
kind: Keyword,
|
||||||
|
},
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "1:n",
|
text: "1:n",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
@@ -34,6 +38,10 @@ Assessment {
|
|||||||
text: "column",
|
text: "column",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "index",
|
||||||
|
kind: Keyword,
|
||||||
|
},
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "1:n",
|
text: "1:n",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
|
|||||||
+47
@@ -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)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+55
@@ -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)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+55
@@ -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)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+63
@@ -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)",
|
||||||
|
),
|
||||||
|
}
|
||||||
+19
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
+19
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
+19
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
+19
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -257,6 +257,7 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
|
|||||||
.collect(),
|
.collect(),
|
||||||
outbound_relationships: Vec::new(),
|
outbound_relationships: Vec::new(),
|
||||||
inbound_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_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
app.update(AppEvent::DslSucceeded {
|
app.update(AppEvent::DslSucceeded {
|
||||||
command: Command::AddRelationship {
|
command: Command::AddRelationship {
|
||||||
@@ -470,6 +472,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
|||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
app.update(AppEvent::DslSucceeded {
|
app.update(AppEvent::DslSucceeded {
|
||||||
command: Command::AddColumn {
|
command: Command::AddColumn {
|
||||||
|
|||||||
Reference in New Issue
Block a user