feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX
Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> -> SqlDropIndex, both reusing the ADR-0025 executors (do_add_index / do_drop_index), like 4c reused do_drop_table. - CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1): ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced mode trusts the user like SQL does. Adds an additive IndexSchema.unique flag (project.yaml, serde-default, version stays 1); rebuild re-emits CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique). Simple-mode `add unique index` stays deferred. - IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome. - Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE] prefix is a concrete-keyword Choice and the optional name an on-led-first selector (the drop-index selector precedent) — trap-safe. - create/drop each gain a second advanced node; the existing all-candidates dispatch handles it (locked by parse tests). - Unique indexes marked [unique] in the structure view and items panel. - do_add_index refuses internal __rdbms_* tables as "no such table", closing a latent exposure on both the simple `add index` and the new SQL CREATE INDEX surfaces (ADR-0025 Amendment 1). Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README; requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md. Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted
|
Accepted. **Amendment 1 (2026-05-25):** UNIQUE indexes are admitted on
|
||||||
|
the **advanced-mode** SQL surface (`CREATE UNIQUE INDEX`) — see
|
||||||
|
*Amendment 1* below and ADR-0035 §4d. The original *Out of scope*
|
||||||
|
exclusion stands for the **simple-mode DSL** (`add unique index` remains
|
||||||
|
deferred).
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -335,6 +339,42 @@ here so the decision text and the code agree:
|
|||||||
list with each table's indexes indented beneath — the
|
list with each table's indexes indented beneath — the
|
||||||
`S2` nested view — reading the two together.
|
`S2` nested view — reading the two together.
|
||||||
|
|
||||||
|
## Amendment 1 — UNIQUE indexes in advanced mode (2026-05-25)
|
||||||
|
|
||||||
|
This ADR's *Out of scope* excluded UNIQUE indexes (`add unique index`)
|
||||||
|
on the grounds that a unique index conflates two concepts the playground
|
||||||
|
teaches separately — an index (a performance structure) and a UNIQUE
|
||||||
|
*constraint* (an integrity rule, tracked as its own `C3` sub-item). That
|
||||||
|
reasoning was written on 2026-05-16, when the **simple-mode DSL was the
|
||||||
|
only input surface**, and it still holds there: simple mode teaches the
|
||||||
|
two concepts separately, so `add unique index` stays deferred.
|
||||||
|
|
||||||
|
ADR-0035 (advanced-mode SQL DDL) introduced a second surface whose
|
||||||
|
explicit posture is to "trust the user like SQL does" (ADR-0035 §7). On
|
||||||
|
that surface `CREATE UNIQUE INDEX` is standard SQL a learner types
|
||||||
|
verbatim, and the concept-separation argument does not transfer — so
|
||||||
|
ADR-0035 §4 lists `CREATE [UNIQUE] INDEX` and **§4d supersedes this
|
||||||
|
ADR's exclusion for the advanced surface**. The constraint track this
|
||||||
|
ADR deferred *to* (ADR-0018 → ADR-0029 → ADR-0035 §4a.2) has since
|
||||||
|
shipped, so there is no remaining dependency.
|
||||||
|
|
||||||
|
Mechanically, the index model gains an `IndexSchema.unique` flag — an
|
||||||
|
additive, `#[serde(default)]` `project.yaml` field (`version` stays
|
||||||
|
`1`); the engine already reports uniqueness via `pragma_index_list`
|
||||||
|
(origin `c`), so **no `__rdbms_*` metadata table is added** (the §Storage
|
||||||
|
decision is unchanged — the divergence from the relationship precedent
|
||||||
|
stands). The rebuild primitive re-emits `CREATE UNIQUE INDEX`; the
|
||||||
|
structure view and items panel mark a unique index `[unique]`
|
||||||
|
(ADR-0035 §4d). The redundant-column-set guard keys on `(columns,
|
||||||
|
unique)` so a plain and a unique index over the same columns are not
|
||||||
|
mutual duplicates.
|
||||||
|
|
||||||
|
The amendment also hardened the shared `do_add_index` executor to refuse
|
||||||
|
an internal `__rdbms_*` table as "no such table" (consistent with the
|
||||||
|
app-wide opacity of internal tables) — closing a latent exposure on
|
||||||
|
*both* the simple `add index` and the new SQL `CREATE INDEX` surfaces,
|
||||||
|
which both reach `do_add_index`.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- ADR-0004 / ADR-0015 (project file format and storage runtime)
|
- ADR-0004 / ADR-0015 (project file format and storage runtime)
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted. Design agreed with the user (2026-05-24); the approach is
|
Accepted. Design agreed with the user (2026-05-24); the approach is
|
||||||
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c**
|
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c / 4d**
|
||||||
(`CREATE TABLE` with column- and table-level constraints and foreign
|
(`CREATE TABLE` with column- and table-level constraints and foreign
|
||||||
keys, and `DROP TABLE [IF EXISTS]`, implemented 2026-05-25 — plans
|
keys, `DROP TABLE [IF EXISTS]`, and `CREATE [UNIQUE] INDEX` /
|
||||||
|
`DROP INDEX [IF EXISTS]`, implemented 2026-05-25 — plans
|
||||||
`docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`,
|
`docs/plans/20260524-adr-0035-sql-ddl-4a.md`, `…-4a2.md`, `…-4a3.md`,
|
||||||
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`), so the
|
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`, `…-4d.md`), so
|
||||||
decision is accepted while the remaining sub-phases (**4d–4i**, §13)
|
the decision is accepted while the remaining sub-phases (**4e–4i**, §13)
|
||||||
continue. This is **Phase 4** of the ADR-0030 roadmap (the
|
continue. This is **Phase 4** of the ADR-0030 roadmap (the
|
||||||
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
|
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
|
||||||
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
|
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
|
||||||
@@ -381,7 +382,25 @@ ADR-0033's structure:
|
|||||||
DSL drops still parse via fallback — 4i grows the surface as `DROP
|
DSL drops still parse via fallback — 4i grows the surface as `DROP
|
||||||
INDEX` lands in 4d.
|
INDEX` lands in 4d.
|
||||||
- **4d — `CREATE [UNIQUE] INDEX` / `DROP INDEX`** → `SqlCreateIndex`
|
- **4d — `CREATE [UNIQUE] INDEX` / `DROP INDEX`** → `SqlCreateIndex`
|
||||||
/ `SqlDropIndex` (ADR-0025; the `UNIQUE` flag extension if needed).
|
/ `SqlDropIndex`. *(Implemented 2026-05-25 — plan
|
||||||
|
`docs/plans/20260525-adr-0035-sql-ddl-4d.md`.)* Both reuse the ADR-0025
|
||||||
|
executors (`do_add_index` / `do_drop_index`), like 4c reused
|
||||||
|
`do_drop_table`. `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T>
|
||||||
|
(cols)` (the unnamed form auto-named per ADR-0025; the leading
|
||||||
|
`[UNIQUE]` is a concrete-keyword `Choice`, the optional name an
|
||||||
|
`on`-led-first selector mirroring the `drop index` positional
|
||||||
|
selector) and `DROP INDEX [IF EXISTS] <name>` (name-only — the
|
||||||
|
positional `drop index on T(…)` stays the simple form via fallback).
|
||||||
|
`IF [NOT] EXISTS` reuses the 4c no-op-with-note skip (journalled, not
|
||||||
|
snapshotted). **`CREATE UNIQUE INDEX` is admitted** (user-confirmed
|
||||||
|
2026-05-25): ADR-0025 deferred UNIQUE indexes for the *simple-mode
|
||||||
|
DSL*, but advanced mode "trusts the user like SQL does" (§7) — so the
|
||||||
|
model gains an `IndexSchema.unique` flag (additive YAML, `version` 1;
|
||||||
|
rebuild re-emits `CREATE UNIQUE INDEX`; the structure view + items
|
||||||
|
panel mark `[unique]`), recorded as **ADR-0025 Amendment 1**.
|
||||||
|
Simple-mode `add unique index` stays deferred. `create`/`drop` each
|
||||||
|
gain a *second* advanced node, exercising the all-candidates dispatch
|
||||||
|
(`decide` tries every advanced candidate).
|
||||||
- **4e — `ALTER TABLE` add/drop/rename column.** Drop/rename column
|
- **4e — `ALTER TABLE` add/drop/rename column.** Drop/rename column
|
||||||
must guard against a **table-level CHECK that references the column**
|
must guard against a **table-level CHECK that references the column**
|
||||||
(4a.3): today the rebuild rejects it cleanly via the engine (the
|
(4a.3): today the rebuild rejects it cleanly via the engine (the
|
||||||
@@ -398,8 +417,13 @@ ADR-0033's structure:
|
|||||||
statement), `help`/usage for the new forms. **Carried in from earlier
|
statement), `help`/usage for the new forms. **Carried in from earlier
|
||||||
slices:** (a) refresh the `CREATE TABLE` help/usage skeleton for the
|
slices:** (a) refresh the `CREATE TABLE` help/usage skeleton for the
|
||||||
4a.2 `DEFAULT`/`CHECK`/composite-`UNIQUE`, 4a.3 table-`CHECK`, and 4b
|
4a.2 `DEFAULT`/`CHECK`/composite-`UNIQUE`, 4a.3 table-`CHECK`, and 4b
|
||||||
FK forms (deferred from each); (b) `describe` display of table-level
|
FK forms (deferred from each) — **4d's index forms already carry their
|
||||||
constraints (composite `UNIQUE` + table `CHECK`); (c) **4b self-ref
|
own help/usage** (`ddl.sql_create_index` / `ddl.sql_drop_index` + the
|
||||||
|
`parse.usage.*` keys), since the nodes are new; (b) `describe` display
|
||||||
|
of table-level constraints (composite `UNIQUE` + table `CHECK`) — note
|
||||||
|
the **unique-*index* marker shipped in 4d** (`[unique]` in the
|
||||||
|
structure view + items panel), so only the table-level *constraint*
|
||||||
|
display remains here; (c) **4b self-ref
|
||||||
FK indicator** — a `CREATE TABLE` with a self-referencing FK
|
FK indicator** — a `CREATE TABLE` with a self-referencing FK
|
||||||
(`references <self>`) parses + executes correctly, but the pre-submit
|
(`references <self>`) parses + executes correctly, but the pre-submit
|
||||||
schema-existence diagnostic falsely flags the not-yet-created self
|
schema-existence diagnostic falsely flags the not-yet-created self
|
||||||
@@ -415,7 +439,10 @@ ADR-0033's structure:
|
|||||||
entry word so advanced completion offers every valid continuation
|
entry word so advanced completion offers every valid continuation
|
||||||
(`drop ` → table + column + relationship + index + constraint; `drop
|
(`drop ` → table + column + relationship + index + constraint; `drop
|
||||||
rel` → relationship); verify `create`/`insert`/`update`/`delete`
|
rel` → relationship); verify `create`/`insert`/`update`/`delete`
|
||||||
completion stays sensible. (e) **Discussion flag (user, 2026-05-25):**
|
completion stays sensible. **4d widened this:** `create` and `drop`
|
||||||
|
now each have *two* advanced nodes (table + index), so a shared entry
|
||||||
|
word's continuations now span two SQL shapes as well as the DSL ones —
|
||||||
|
the merge matters more. (e) **Discussion flag (user, 2026-05-25):**
|
||||||
before/with (d), discuss **visually distinguishing simple- vs
|
before/with (d), discuss **visually distinguishing simple- vs
|
||||||
advanced-mode completions in the hint UI (likely by colour)** so a
|
advanced-mode completions in the hint UI (likely by colour)** so a
|
||||||
learner can see which continuations are DSL and which are SQL — a UX
|
learner can see which continuations are DSL and which are SQL — a UX
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,439 @@
|
|||||||
|
# Plan: ADR-0035 Phase 4, sub-phase 4d — `CREATE [UNIQUE] INDEX` / `DROP INDEX`
|
||||||
|
|
||||||
|
Add advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON
|
||||||
|
<table> (<col>, …)` → `SqlCreateIndex` and `DROP INDEX [IF EXISTS]
|
||||||
|
<name>` → `SqlDropIndex` (ADR-0035 §1/§4/§13). Both reuse the existing
|
||||||
|
ADR-0025 low-level executors (`do_add_index` / `do_drop_index`), like 4c
|
||||||
|
reused `do_drop_table`. Two genuinely new things beyond 4c's pattern:
|
||||||
|
|
||||||
|
1. **`UNIQUE` index → an `IndexSchema.unique` model extension** (the
|
||||||
|
escalation; user-approved 2026-05-25). ADR-0025 deferred UNIQUE
|
||||||
|
indexes for the *simple-mode DSL* (`add unique index`); ADR-0035 §4
|
||||||
|
supersedes that for the *advanced* SQL surface. Simple-mode
|
||||||
|
`add unique index` stays deferred. An ADR-0025 **Amendment** records
|
||||||
|
this.
|
||||||
|
2. **A second advanced node per entry word.** `create` gains
|
||||||
|
`SQL_CREATE_INDEX` alongside `SQL_CREATE_TABLE`; `drop` gains
|
||||||
|
`SQL_DROP_INDEX` alongside `SQL_DROP_TABLE`. The dispatcher already
|
||||||
|
tries *all* advanced candidates (`decide`: `advanced.chain(simple)` +
|
||||||
|
the first-full-match loop), so no dispatch change is needed — but a
|
||||||
|
parse test locks it.
|
||||||
|
|
||||||
|
`IF [NOT] EXISTS` on **both** forms (user-approved 2026-05-25), reusing
|
||||||
|
the 4c no-op-with-note skip path.
|
||||||
|
|
||||||
|
## 1. Baseline
|
||||||
|
|
||||||
|
- Tests: **1805 passing, 0 failing, 0 skipped, 1 ignored** (the
|
||||||
|
`friendly/mod.rs` ```ignore``` doctest); clippy clean. Branch `main`,
|
||||||
|
HEAD `e52e90c` (4c), 3 local-only commits since `origin/main`. 4d
|
||||||
|
starts here.
|
||||||
|
|
||||||
|
## 2. Decisions (settled — escalated + user-confirmed 2026-05-25)
|
||||||
|
|
||||||
|
1. **`CREATE UNIQUE INDEX` is in; the model extension lands now.**
|
||||||
|
`IndexSchema` gains `unique: bool`. ADR-0025's UNIQUE-index exclusion
|
||||||
|
was a statement about the *simple-mode DSL teaching surface* (the only
|
||||||
|
surface that existed on 2026-05-16); advanced mode "trusts the user
|
||||||
|
like SQL does" (ADR-0035 §7), so the concept-separation argument does
|
||||||
|
not transfer. The constraint track it deferred *to* (ADR-0018 → 0029 →
|
||||||
|
0035 4a.2) has since shipped. ADR-0025 gets an Amendment; **simple-mode
|
||||||
|
`add unique index` stays deferred** (not in this slice).
|
||||||
|
2. **`IF [NOT] EXISTS` on both forms** — universal cross-vendor idiom
|
||||||
|
(ADR-0035 §4 reclassification), reusing the 4c
|
||||||
|
`CreateOutcome`/`DropOutcome`-style no-op-with-note. Skip is
|
||||||
|
**journalled, not snapshotted**.
|
||||||
|
3. **Reuse the ADR-0025 executors.** `do_add_index` / `do_drop_index` are
|
||||||
|
the single executors; the SQL path differs only at the `unique` flag
|
||||||
|
and the `IF [NOT] EXISTS` skip branch. No fork.
|
||||||
|
4. **`DROP INDEX <name>` is name-only.** SQL has no positional
|
||||||
|
`on T (cols)` drop form (that is the simple DSL `drop index on …`,
|
||||||
|
which keeps falling back to the simple `drop` node). `SqlDropIndex`
|
||||||
|
carries `{ name, if_exists }`; the worker builds
|
||||||
|
`IndexSelector::Named { name }` for `do_drop_index`.
|
||||||
|
5. **Unnamed `CREATE INDEX` is supported and auto-named** (ADR-0035 §4
|
||||||
|
`[<name>]`; the ADR-0025 `<T>_<cols>_idx` convention). A playground
|
||||||
|
convenience over SQLite (which requires a name). See §4.1 for the
|
||||||
|
grammar-disambiguation risk this carries.
|
||||||
|
|
||||||
|
## 3. Phase 1 — Requirements checklist (4d)
|
||||||
|
|
||||||
|
Grammar / parse:
|
||||||
|
- [ ] `CREATE INDEX <name> ON <T> (<cols>)` parses in advanced mode →
|
||||||
|
`SqlCreateIndex { unique: false, if_not_exists: false }`.
|
||||||
|
- [ ] `CREATE UNIQUE INDEX <name> ON …` sets `unique: true`.
|
||||||
|
- [ ] `CREATE INDEX ON <T> (<cols>)` (no name) parses → `name: None`
|
||||||
|
(auto-named at execution). The optional name must **not** swallow `on`.
|
||||||
|
- [ ] `CREATE [UNIQUE] INDEX IF NOT EXISTS <name> ON …` sets
|
||||||
|
`if_not_exists: true`.
|
||||||
|
- [ ] `DROP INDEX <name>` → `SqlDropIndex { if_exists: false }`;
|
||||||
|
`DROP INDEX IF EXISTS <name>` → `if_exists: true`.
|
||||||
|
- [ ] Trailing `;` tolerated on both (ADR-0035 §12).
|
||||||
|
- [ ] **Dispatch (second advanced node):** in advanced mode
|
||||||
|
`create table …` → `SqlCreateTable`, `create [unique] index …` →
|
||||||
|
`SqlCreateIndex`; `drop table …` → `SqlDropTable`, `drop index …` →
|
||||||
|
`SqlDropIndex`. Simple `add index` / `drop index on T(…)` /
|
||||||
|
`drop column` etc. still parse (fallback).
|
||||||
|
|
||||||
|
Execution / model:
|
||||||
|
- [ ] `CREATE INDEX` creates a plain index (parity with `add index`);
|
||||||
|
one undo step; `undo` removes it.
|
||||||
|
- [ ] `CREATE UNIQUE INDEX` creates a unique index; it round-trips
|
||||||
|
through `project.yaml` (the `unique` flag) and survives `rebuild`
|
||||||
|
(re-emitted as `CREATE UNIQUE INDEX`); enforces uniqueness (a duplicate
|
||||||
|
insert is refused by the engine).
|
||||||
|
- [ ] `DROP INDEX` removes the index; one undo step; `undo` restores it;
|
||||||
|
the affected table's structure is auto-shown (ADR-0014).
|
||||||
|
- [ ] `IF NOT EXISTS` on an existing index name → success + note, no
|
||||||
|
error, no snapshot, journalled. `IF EXISTS` on an absent index →
|
||||||
|
ditto.
|
||||||
|
- [ ] Plain `CREATE INDEX` on a duplicate name / `DROP INDEX` on an
|
||||||
|
unknown name → the existing friendly errors (unchanged).
|
||||||
|
- [ ] Redundant-column-set guard refined for uniqueness: a plain and a
|
||||||
|
unique index over the same columns are **not** mutual duplicates
|
||||||
|
(different semantics); an exact duplicate (same columns **and** same
|
||||||
|
uniqueness) is still refused. *(Test must use **explicit distinct
|
||||||
|
names** for the plain+unique pair — the auto-name `<T>_<cols>_idx` is
|
||||||
|
identical for both, so an unnamed second would collide on the name
|
||||||
|
guard regardless. DA finding D.)*
|
||||||
|
- [ ] `CREATE UNIQUE INDEX` on a column that **already holds duplicate
|
||||||
|
values** is refused by the engine at creation; the failure routes
|
||||||
|
through the friendly layer, engine-neutral. *(Test.)*
|
||||||
|
- [ ] `IF NOT EXISTS` only short-circuits a **name** collision into a
|
||||||
|
skip; a *different*-named but redundant-column-set create still hits
|
||||||
|
the ADR-0025 redundant-set refusal (the playground's pedagogical guard,
|
||||||
|
not raw-SQL semantics). *(Test + one-line doc note. DA finding H.)*
|
||||||
|
- [ ] Errors/notes engine-neutral.
|
||||||
|
|
||||||
|
Persistence round-trip:
|
||||||
|
- [ ] `IndexSchema.unique` round-trips: save → YAML `unique: true` →
|
||||||
|
load → identical snapshot. Older project files (no `unique` field)
|
||||||
|
default to `false` (`#[serde(default)]`); `version` stays `1`.
|
||||||
|
- [ ] The rebuild-table primitive preserves a unique index's uniqueness
|
||||||
|
(it re-creates captured indexes; `IndexInfo.unique` already read —
|
||||||
|
verify the re-emit honours it).
|
||||||
|
|
||||||
|
Display (unique marker — user-confirmed for 4d):
|
||||||
|
- [ ] Structure view marks a unique index — `… (cols) [unique]` — and
|
||||||
|
leaves a plain index unmarked.
|
||||||
|
- [ ] Items list (left panel) marks a unique index name.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] **Tier 1** (in-crate `sql_create_index_tests` / `sql_drop_index_tests`
|
||||||
|
in `ddl.rs`, mirroring `sql_drop_table_tests`): the parse + flag cases
|
||||||
|
above + the dispatch (second-advanced-node) cases.
|
||||||
|
- [ ] **Tier 3** (`tests/sql_create_index.rs`, `tests/sql_drop_index.rs`):
|
||||||
|
create plain/unique, the YAML round-trip + rebuild survival of a unique
|
||||||
|
index, uniqueness enforcement, `IF [NOT] EXISTS` skip + journal, plain
|
||||||
|
duplicate/unknown errors, one undo step + restore, drop auto-show.
|
||||||
|
- [ ] **Tier 2** (insta): a `project.yaml` snapshot carrying a
|
||||||
|
`unique: true` index, if an existing yaml snapshot test covers indexes
|
||||||
|
(extend it; else a focused new one).
|
||||||
|
- [ ] **Catalog** lockstep + vocab audit for the new note + help/usage
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## 4. Architecture & design
|
||||||
|
|
||||||
|
### 4.1 Grammar (`src/dsl/grammar/ddl.rs`)
|
||||||
|
|
||||||
|
**`DROP INDEX` (easy, mirrors `SQL_DROP_TABLE`).**
|
||||||
|
- `SQL_DROP_INDEX_SHAPE = Seq[ Word("index"), SQL_DROP_IF_EXISTS_OPT,
|
||||||
|
INDEX_NAME_EXISTING, Optional(Punct(';')) ]`. Reuse the existing
|
||||||
|
`SQL_DROP_IF_EXISTS_OPT` and `INDEX_NAME_EXISTING` nodes.
|
||||||
|
- `pub static SQL_DROP_INDEX: CommandNode { entry: "drop", shape,
|
||||||
|
ast_builder: build_sql_drop_index, help_id, usage_ids }`.
|
||||||
|
- `build_sql_drop_index`: `name = require_ident("index_name")`,
|
||||||
|
`if_exists = path.contains_word("if")`.
|
||||||
|
|
||||||
|
**`CREATE INDEX`.** The shape, after the `create` entry word, must lead
|
||||||
|
with a concrete keyword (the leading-`Optional` trap, handoff §3 /
|
||||||
|
pattern 5). Both sub-problems are **settled by existing precedent** (DA
|
||||||
|
pass verified against the walker code, not reasoned):
|
||||||
|
|
||||||
|
- *`[UNIQUE]` optional prefix.* Lead with a `Choice` whose every branch
|
||||||
|
starts on a concrete keyword: `UNIQUE_INDEX_LEAD = Choice[
|
||||||
|
Seq[Word("unique"), Word("index")], Word("index") ]`. A leading
|
||||||
|
**`Choice`** of concrete-keyword branches is exactly the sanctioned
|
||||||
|
pattern (the §3 rule forbids a leading *`Optional`*, not a `Choice`);
|
||||||
|
`unique` presence is read in the builder via
|
||||||
|
`path.contains_word("unique")`. Smoke-probe only.
|
||||||
|
- *Optional `[<name>]` before `on` — use the `DI_SELECTOR` precedent, not
|
||||||
|
a bare `Optional`.* **Verified:** `walk_ident` → `consume_ident` does
|
||||||
|
**not** reject reserved keywords, so a bare `Optional(<name ident>)`
|
||||||
|
*would* greedily eat the `on` keyword and break the unnamed form. The
|
||||||
|
drop-index selector already solves this exact shape: a `Choice` with
|
||||||
|
the **`on`-led branch first**, relying on `Choice` backtracking. Mirror
|
||||||
|
it:
|
||||||
|
- `CI_UNNAMED = Seq[ Word("on"), TABLE_NAME_EXISTING, Punct('('),
|
||||||
|
INDEX_COLUMN_LIST, Punct(')') ]`
|
||||||
|
- `CI_NAMED = Seq[ INDEX_NAME_NEW, Word("on"), TABLE_NAME_EXISTING,
|
||||||
|
Punct('('), INDEX_COLUMN_LIST, Punct(')') ]`
|
||||||
|
- `CI_SELECTOR = Choice[ CI_UNNAMED, CI_NAMED ]` (unnamed first, like
|
||||||
|
`DI_SELECTOR`'s `DI_POSITIONAL`-first). `create index on T (c)` →
|
||||||
|
`CI_UNNAMED` (no name-slot to eat `on`); `create index ix on T (c)` →
|
||||||
|
`CI_UNNAMED` fails at `Word(on)` vs `ix`, backtracks to `CI_NAMED`.
|
||||||
|
- Full shape: `Seq[ UNIQUE_INDEX_LEAD,
|
||||||
|
SQL_CREATE_INDEX_IF_NOT_EXISTS_OPT, CI_SELECTOR, Optional(Punct(';')) ]`.
|
||||||
|
The `IF NOT EXISTS` opt is mid-`Seq` (not leading) — exactly like 4c's
|
||||||
|
`SQL_DROP_TABLE` `IF EXISTS` opt, so it is trap-safe.
|
||||||
|
- `build_sql_create_index`: `unique = contains_word("unique")`,
|
||||||
|
`if_not_exists = contains_word("if")`, `name = optional_ident(
|
||||||
|
"index_name")` (present iff the `CI_NAMED` branch matched),
|
||||||
|
`table = require_ident("table_name")`, `columns` from the list.
|
||||||
|
|
||||||
|
The single-node form is now the *primary* approach (no probe gamble): the
|
||||||
|
two-node `TABLE_FK`-style split is **not needed** — kept only as a
|
||||||
|
contingency if the smoke-probe of the leading `Choice` surprises.
|
||||||
|
|
||||||
|
**REGISTRY (`mod.rs`):** add `(&ddl::SQL_CREATE_INDEX,
|
||||||
|
CommandCategory::Advanced)` and `(&ddl::SQL_DROP_INDEX,
|
||||||
|
CommandCategory::Advanced)`. Verify `decide` tries all advanced
|
||||||
|
candidates (it does — locked by a parse test).
|
||||||
|
|
||||||
|
### 4.2 Command (`src/dsl/command.rs`)
|
||||||
|
- `SqlCreateIndex { name: Option<String>, table: String, columns:
|
||||||
|
Vec<String>, unique: bool, if_not_exists: bool }` — verb
|
||||||
|
`"create index"`, `target_table()` → `table`.
|
||||||
|
- `SqlDropIndex { name: String, if_exists: bool }` — verb
|
||||||
|
`"drop index"`, `target_table()` → `name` (the index name is the
|
||||||
|
identifying thing for logging; mirrors `DropIndex`/`SqlDropTable`).
|
||||||
|
- Exhaustive-match fallout (compiler-found): `verb`, `target_table` in
|
||||||
|
`command.rs`; `app.rs` failure-translate; `runtime.rs` dispatch +
|
||||||
|
outcome→event; `tests/typing_surface/mod.rs` `command_label` arms
|
||||||
|
(`SqlCreateIndex`/`SqlDropIndex`, alongside the existing
|
||||||
|
`SqlCreateTable`/`SqlDropTable`). Add the SQL index forms to the
|
||||||
|
`tests/typing_surface/index_ops.rs` matrix (+ insta snaps) next to the
|
||||||
|
DSL `add index`/`drop index` cases.
|
||||||
|
- **`IndexSchema` struct-literal fallout** (compiler-found, the new
|
||||||
|
`unique` field): db.rs:2414, yaml.rs:299, the yaml round-trip test
|
||||||
|
(~478) — each adds `unique: …`.
|
||||||
|
|
||||||
|
### 4.3 Model extension — `IndexSchema.unique` (`src/persistence/`, `src/db.rs`)
|
||||||
|
- `persistence/mod.rs`: `IndexSchema` gains `pub unique: bool` (doc-note
|
||||||
|
mirroring `TableSchema.unique_constraints`: additive, defaults `false`
|
||||||
|
for older files, `version` stays `1`).
|
||||||
|
- `db.rs read_schema_snapshot` (~2414): carry `idx.unique` (already read
|
||||||
|
into `IndexInfo` by `read_table_indexes`).
|
||||||
|
- `yaml.rs`: the raw deserialize struct gains `#[serde(default)] unique:
|
||||||
|
bool`; the reader (~299) carries it; `write_index` (~66) emits
|
||||||
|
`unique: true` **only when true** (omit when false — keeps existing
|
||||||
|
project files byte-stable and matches the minimal-noise house style;
|
||||||
|
confirm against any golden-yaml test).
|
||||||
|
- `db.rs rebuild_from_text` (~7814): emit `CREATE UNIQUE INDEX` when
|
||||||
|
`index.unique`, else `CREATE INDEX`.
|
||||||
|
- `do_add_index` gains `unique: bool`: emit `CREATE UNIQUE INDEX` when
|
||||||
|
set; **refine the redundant-set guard** to compare `(columns, unique)`
|
||||||
|
so a plain vs unique index over the same columns is not a false
|
||||||
|
duplicate. Simple `add index` passes `unique: false` (call-site
|
||||||
|
update in the worker arm).
|
||||||
|
|
||||||
|
### 4.4 Worker (`src/db.rs`)
|
||||||
|
- `Request::SqlCreateIndex { name, table, columns, unique, if_not_exists,
|
||||||
|
source, reply: oneshot<Result<CreateIndexOutcome>> }` +
|
||||||
|
`db.sql_create_index(...)`.
|
||||||
|
- Skip branch: resolve the index name (given, or the `<T>_<cols>_idx`
|
||||||
|
auto-name — extract a tiny `resolve_index_name` helper shared with
|
||||||
|
`do_add_index`), and if `if_not_exists && index_name_exists(resolved)`
|
||||||
|
→ journal the line (no snapshot), reply `CreateIndexOutcome::Skipped(
|
||||||
|
resolved)`. Else `snapshot_then(do_add_index(..., unique) →
|
||||||
|
Created(desc))`.
|
||||||
|
- `Request::SqlDropIndex { name, if_exists, source, reply:
|
||||||
|
oneshot<Result<DropIndexOutcome>> }` + `db.sql_drop_index(...)`.
|
||||||
|
- Skip branch: if `if_exists && !index_name_exists(name)` → journal (no
|
||||||
|
snapshot), reply `Skipped`. Else `snapshot_then(do_drop_index(
|
||||||
|
IndexSelector::Named { name }) → Dropped(desc))`.
|
||||||
|
- New outcome enums (the index peers of `CreateOutcome`/`DropOutcome`):
|
||||||
|
- `CreateIndexOutcome { Created(TableDescription), Skipped(String) }`
|
||||||
|
(skip carries the **resolved** name — the auto-name is unknown to the
|
||||||
|
command for the unnamed form).
|
||||||
|
- `DropIndexOutcome { Dropped(TableDescription), Skipped }` (drop-skip
|
||||||
|
note uses the command's `name` directly).
|
||||||
|
- `index_name_exists(conn, name)` helper (the `sqlite_master WHERE
|
||||||
|
type='index' AND name=?` lookup `do_add_index` step 5 already does).
|
||||||
|
|
||||||
|
### 4.5 Runtime + event + app
|
||||||
|
- `runtime.rs`: `SqlCreateIndex` → `db.sql_create_index` → `Created(d)` →
|
||||||
|
`CommandOutcome::Schema(Some(d))`, `Skipped(name)` → new
|
||||||
|
`CommandOutcome::SchemaCreateIndexSkipped(name)`. `SqlDropIndex` →
|
||||||
|
`Dropped(d)` → `Schema(Some(d))` (auto-show the de-indexed table),
|
||||||
|
`Skipped` → new `CommandOutcome::SchemaDropIndexSkipped`.
|
||||||
|
- `event.rs`: `DslCreateIndexSkipped { command, name }`,
|
||||||
|
`DslDropIndexSkipped { command }`.
|
||||||
|
- `app.rs`: note `ddl.create_index_skipped_exists` (name from the event)
|
||||||
|
/ `ddl.drop_index_skipped_absent` (name from `command.target_table()`).
|
||||||
|
Both **note-only, no structure** (mirrors the drop-table skip — the
|
||||||
|
index already exists / never existed; showing the whole table is
|
||||||
|
noise). Add the failure-translate arms for the two new commands.
|
||||||
|
|
||||||
|
### 4.7 Unique-index display (user-confirmed for 4d, 2026-05-25)
|
||||||
|
A learner must be able to tell a UNIQUE index from a plain one.
|
||||||
|
- **Structure view (cheap — `IndexInfo.unique` already populated):**
|
||||||
|
`output_render.rs:143` appends a ` [unique]` marker when
|
||||||
|
`index.unique`, e.g. `Customers_email_idx (Email) [unique]`. A render
|
||||||
|
test + any insta snapshot update. No new threading.
|
||||||
|
- **Items list (left panel):** `SchemaCache.table_indexes` is
|
||||||
|
`HashMap<String, Vec<String>>` (names only). Carry uniqueness — change
|
||||||
|
the value to a small `{ name, unique }` entry (or `Vec<(String,
|
||||||
|
bool)>`), populate it from the schema-cache refresh (the `unique` bit
|
||||||
|
is on the read), and render `name [unique]` in `ui.rs:516`. Update the
|
||||||
|
`items_panel_nests_indexes_under_their_table` test + any callers
|
||||||
|
constructing `table_indexes`.
|
||||||
|
- Marker wording engine-neutral (no PRAGMA/SQLite leakage) — `[unique]`
|
||||||
|
is a plain English adjective, fine.
|
||||||
|
|
||||||
|
### 4.6 Friendly catalog (`keys.rs` + `en-US.yaml`)
|
||||||
|
New keys (engine-neutral; lockstep + vocab audit enforce both):
|
||||||
|
- `ddl.create_index_skipped_exists` → `"index '{name}' already exists —
|
||||||
|
skipped (no changes made)"` (`&["name"]`).
|
||||||
|
- `ddl.drop_index_skipped_absent` → `"index '{name}' doesn't exist —
|
||||||
|
skipped (no changes made)"` (`&["name"]`).
|
||||||
|
- `help.ddl.sql_create_index` + `parse.usage.sql_create_index`,
|
||||||
|
`help.ddl.sql_drop_index` + `parse.usage.sql_drop_index` (fresh nodes;
|
||||||
|
their keys must exist now — unlike the deferred *refresh* of the
|
||||||
|
create-table skeleton in 4i(a)).
|
||||||
|
|
||||||
|
## 5. Phase 2 — Candidate approaches
|
||||||
|
|
||||||
|
**(C1) CREATE INDEX grammar — single node w/ leading `Choice`** *(lead)*.
|
||||||
|
One `SQL_CREATE_INDEX` node; `[UNIQUE]` via a leading `Choice` of
|
||||||
|
concrete-keyword-led branches; `unique` read in the builder.
|
||||||
|
- *Good:* one node, one builder, one REGISTRY entry; matches how a single
|
||||||
|
statement maps to a single command.
|
||||||
|
- *Risk:* a leading `Choice` in a top-level shape is untested here; the
|
||||||
|
unnamed-form `on` disambiguation. Both are **probe-or-fall-back**, not
|
||||||
|
blockers.
|
||||||
|
|
||||||
|
**(C2) CREATE INDEX grammar — two nodes split on `unique`/`index`**
|
||||||
|
(the 4c `TABLE_FK` split). `SQL_CREATE_INDEX` (`index`-led) +
|
||||||
|
`SQL_CREATE_INDEX_UNIQUE` (`unique`-led).
|
||||||
|
- *Good:* each node leads on a concrete keyword — guaranteed safe past the
|
||||||
|
trap; `create` then has three advanced nodes, exercising the
|
||||||
|
all-candidates dispatch even harder (a feature for the test).
|
||||||
|
- *Bad:* duplicated shape/builder; more REGISTRY noise. Adopt **only if
|
||||||
|
C1's leading `Choice` probes badly.**
|
||||||
|
|
||||||
|
**(M1) Model extension — `IndexSchema.unique` flag** *(lead; the only
|
||||||
|
real option)*. Mirrors `TableSchema.unique_constraints` exactly (additive,
|
||||||
|
serde-default, version 1). The read side is already half-done
|
||||||
|
(`IndexInfo.unique`).
|
||||||
|
**(M2) Separate `__rdbms_playground_indexes` metadata table** —
|
||||||
|
*rejected.* ADR-0025 deliberately stores nothing app-specific for indexes
|
||||||
|
(SQLite owns the index namespace, incl. uniqueness via
|
||||||
|
`pragma_index_list`). A metadata table would duplicate engine-owned state
|
||||||
|
and create a consistency hazard. The flag is read straight from the
|
||||||
|
pragma.
|
||||||
|
|
||||||
|
**(S1) Skip plumbing — dedicated index skip outcomes/events** *(lead)*.
|
||||||
|
New `CreateIndexOutcome`/`DropIndexOutcome` + `DslCreate/DropIndexSkipped`
|
||||||
|
events + index-specific notes. Consistent with 4a/4c (one event per skip
|
||||||
|
kind).
|
||||||
|
**(S2) Generalise the existing skip events to be object-agnostic** —
|
||||||
|
*rejected.* Would rewrite 4a/4c wording/behaviour for marginal saving;
|
||||||
|
churn on shipped code.
|
||||||
|
|
||||||
|
## 6. Phase 3 — Selection vs the checklist
|
||||||
|
|
||||||
|
C1 + M1 + S1 satisfy every §3 item: parse/flags (C1 grammar + builder),
|
||||||
|
dispatch (REGISTRY + the existing all-candidates `decide`), execution
|
||||||
|
(reuse `do_add_index`/`do_drop_index` + the `unique` param), persistence
|
||||||
|
round-trip + rebuild survival (M1), skip semantics (S1 + the 4c skip
|
||||||
|
branch), engine-neutral strings (catalog keys under the vocab audit).
|
||||||
|
C1's two risks are bounded with C2 as the pre-identified fallback —
|
||||||
|
neither leaves a requirement unmet. **Selected: C1 (probe; C2 fallback) +
|
||||||
|
M1 + S1.**
|
||||||
|
|
||||||
|
## 7. Devil's Advocate review of this plan
|
||||||
|
|
||||||
|
- **The escalation actually escalated?** Yes — `IndexSchema.unique` + the
|
||||||
|
ADR-0025 supersession were put to the user (2026-05-25) and approved,
|
||||||
|
with simple-mode `add unique index` explicitly kept deferred. The
|
||||||
|
`IF [NOT] EXISTS` scope was also confirmed (both forms). No autonomous
|
||||||
|
scope decision. ✓
|
||||||
|
- **Reuse vs fork?** `do_add_index`/`do_drop_index` stay the single
|
||||||
|
executors; the SQL path adds only `unique` + the skip branch. The
|
||||||
|
redundant-set guard refinement is a *correctness fix the model
|
||||||
|
extension forces* (plain vs unique are not duplicates), not a fork. ✓
|
||||||
|
- **Grammar risk owned — verified in code, not reasoned.** (1)
|
||||||
|
`INDEX_NAME_EXISTING`/`TABLE_NAME_EXISTING` have `validator: None`, so
|
||||||
|
`DROP INDEX IF EXISTS <absent>` parses and reaches the skip path (no
|
||||||
|
hard existence check at parse). (2) `walk_ident`→`consume_ident` does
|
||||||
|
**not** reject keywords, so a bare `Optional(<name>)` would eat `on` —
|
||||||
|
hence the `DI_SELECTOR`-style `on`-led-first `Choice` (§4.1), proven by
|
||||||
|
the shipped drop-index selector. (3) Trailing input after a matched
|
||||||
|
shape is a `Mismatch` (not `Match`), and `decide` commits only on a
|
||||||
|
full `Match`, so `drop index on T (c)` fails the name-only
|
||||||
|
`SQL_DROP_INDEX` and falls back to the simple `DI_POSITIONAL` — the
|
||||||
|
advanced-mode fallback for the positional drop is intact. A parse test
|
||||||
|
for `create index on T (c)` → `name: None` and a fallback test for
|
||||||
|
`drop index on T (c)` (advanced) → simple `DropIndex` are on the
|
||||||
|
checklist. ✓
|
||||||
|
- **Second advanced node verified, not assumed?** `decide` is read
|
||||||
|
(advanced.chain(simple) + first-full-match loop) and a dispatch parse
|
||||||
|
test is on the checklist (`create table`/`create index`/`drop
|
||||||
|
table`/`drop index` each to the right command in advanced mode). ✓
|
||||||
|
- **Round-trip + rebuild for the new flag?** Explicit checklist items:
|
||||||
|
YAML `unique: true` save/load identity, older-file default, rebuild
|
||||||
|
re-emits `CREATE UNIQUE INDEX`, uniqueness actually enforced after
|
||||||
|
rebuild. Two DDL generators stay in sync (pattern 1): `do_add_index`
|
||||||
|
and `rebuild_from_text` both gain the `UNIQUE` emit. ✓
|
||||||
|
- **Undo parity?** One step per statement (`snapshot_then`); skip is
|
||||||
|
journalled-not-snapshotted (4c pattern). Undo tests for both create and
|
||||||
|
drop. ✓
|
||||||
|
- **Anything silently dropped?** Simple-mode `add unique index` is
|
||||||
|
*deferred by user decision*, recorded in the ADR-0025 Amendment — not
|
||||||
|
silently. The 4i list grows by one (help/usage skeleton refresh for the
|
||||||
|
new index forms — but these nodes' keys land now, only the *unified
|
||||||
|
CREATE TABLE* skeleton refresh stays in 4i(a)). Completion merge for
|
||||||
|
the now-two-advanced-node `create`/`drop` widens 4i(d) — already
|
||||||
|
tracked, user-confirmed deferred. ✓
|
||||||
|
- **`describe`/items-list of a unique index — RESOLVED (user chose "add
|
||||||
|
in 4d", 2026-05-25).** The structure view rendered every index as
|
||||||
|
`name (cols)` with no uniqueness marker — a pedagogy gap ("can't tell a
|
||||||
|
UNIQUE index from a plain one"). Now in scope: §4.7. Structure view is
|
||||||
|
cheap (`IndexInfo.unique` already populated); the items list needs the
|
||||||
|
`SchemaCache.table_indexes` value to carry the bit. Both on the
|
||||||
|
checklist. ✓
|
||||||
|
|
||||||
|
## 8. Out of 4d scope (tracked, not dropped)
|
||||||
|
- Simple-mode `add unique index` — deferred by user decision (ADR-0025
|
||||||
|
Amendment).
|
||||||
|
- `ALTER TABLE` (4e–4h).
|
||||||
|
- 4i(a) CREATE TABLE help/usage skeleton refresh; 4i(b) `describe` of
|
||||||
|
**table-level constraints** (composite `UNIQUE` + table `CHECK`) — the
|
||||||
|
*unique-index* display moves **into 4d** (§4.7), but the table-level
|
||||||
|
constraint display stays 4i(b); 4i(d) shared-entry-word completion
|
||||||
|
merge (now widened by `create`/`drop` having two advanced nodes);
|
||||||
|
4i(e) the simple/advanced completion-colour UX discussion.
|
||||||
|
|
||||||
|
## 9. Implementation sequence (test-first)
|
||||||
|
1. **Model extension first (M1), isolated** — add `IndexSchema.unique` +
|
||||||
|
yaml round-trip + `read_schema_snapshot` + rebuild emit + `do_add_index`
|
||||||
|
`unique` param (default-`false` call site) + the dup-guard refinement.
|
||||||
|
Red tests: YAML round-trip of a unique index, rebuild survival,
|
||||||
|
uniqueness enforced, plain≠unique not-a-duplicate → green. **No SQL
|
||||||
|
surface yet** — this keeps the model change reviewable on its own and
|
||||||
|
green before any grammar lands.
|
||||||
|
2. **DROP INDEX (simpler grammar)** — Tier-1 parse + dispatch tests →
|
||||||
|
command/grammar/builder/REGISTRY/worker(`DropIndexOutcome`+skip)/
|
||||||
|
runtime/event/app/catalog → Tier-3 (`tests/sql_drop_index.rs`) → green.
|
||||||
|
3. **CREATE INDEX** — smoke-probe the leading `Choice`; Tier-1 parse +
|
||||||
|
flag + dispatch tests (incl. `create index on T (c)` → `name: None`)
|
||||||
|
→ command/grammar/builder/REGISTRY/worker(`CreateIndexOutcome`+skip)/
|
||||||
|
runtime/event/app/catalog → Tier-3 (`tests/sql_create_index.rs`,
|
||||||
|
incl. unique create + round-trip + rebuild + IF NOT EXISTS skip +
|
||||||
|
duplicate-data refusal) → green.
|
||||||
|
4. **Unique-index display (§4.7)** — structure-view `[unique]` marker
|
||||||
|
(render test) + items-list marker (`SchemaCache.table_indexes` carries
|
||||||
|
the bit; ui test) → green.
|
||||||
|
5. **Full sweep** — `cargo test` (no regression from 1805) + `cargo
|
||||||
|
clippy --all-targets -- -D warnings`.
|
||||||
|
6. **Docs** — ADR-0035 Status note + §13 4d; **ADR-0025 Amendment** (the
|
||||||
|
UNIQUE-index supersession for advanced mode) + README index-upkeep;
|
||||||
|
`requirements.md` Q1/C3. Run `/runda` over this slice. Propose commit;
|
||||||
|
wait for approval.
|
||||||
|
|
||||||
|
## 10. Exit gate
|
||||||
|
- All §3 items satisfied; four tiers green, zero skips; no regression from
|
||||||
|
the 1805 baseline; `/runda` / written-DA PASS; clippy clean; ADR-0025
|
||||||
|
Amendment + ADR-0035 §13 4d + README + `requirements.md` updated
|
||||||
|
lockstep.
|
||||||
@@ -168,7 +168,10 @@ handoff-14 cleanup; 449 after B2/C2.)
|
|||||||
inbound view in the structure renderer; type compatibility
|
inbound view in the structure renderer; type compatibility
|
||||||
validated at declaration via `Type::fk_target_type()`.
|
validated at declaration via `Type::fk_target_type()`.
|
||||||
Indexes done (ADR-0025) — `add index` / `drop index`,
|
Indexes done (ADR-0025) — `add index` / `drop index`,
|
||||||
rebuild-preserving, persisted in `project.yaml`.
|
rebuild-preserving, persisted in `project.yaml`; UNIQUE indexes
|
||||||
|
added on the advanced-mode SQL surface (`CREATE UNIQUE INDEX`,
|
||||||
|
ADR-0035 §4d / ADR-0025 Amendment 1; simple-mode `add unique index`
|
||||||
|
deferred).
|
||||||
`NOT NULL` / `UNIQUE` / `CHECK` / `DEFAULT` done (ADR-0029) —
|
`NOT NULL` / `UNIQUE` / `CHECK` / `DEFAULT` done (ADR-0029) —
|
||||||
a constraint suffix on `create table` / `add column`, plus
|
a constraint suffix on `create table` / `add column`, plus
|
||||||
`add constraint` / `drop constraint` on existing columns;
|
`add constraint` / `drop constraint` on existing columns;
|
||||||
@@ -224,9 +227,11 @@ handoff-14 cleanup; 449 after B2/C2.)
|
|||||||
keys (4b — inline `REFERENCES` + table-level `FOREIGN KEY` → ADR-0013
|
keys (4b — inline `REFERENCES` + table-level `FOREIGN KEY` → ADR-0013
|
||||||
named relationships in the create transaction; self-references and
|
named relationships in the create transaction; self-references and
|
||||||
bare `REFERENCES <parent>` supported), then `DROP TABLE [IF EXISTS]`
|
bare `REFERENCES <parent>` supported), then `DROP TABLE [IF EXISTS]`
|
||||||
(4c — reuses `do_drop_table`; `IF EXISTS` is a no-op-with-note)).
|
(4c — reuses `do_drop_table`; `IF EXISTS` is a no-op-with-note), then
|
||||||
Remaining DDL — indexes (4d), `ALTER TABLE` (4e–4h) — is phased per
|
`CREATE [UNIQUE] INDEX` / `DROP INDEX [IF EXISTS]` (4d — reuse
|
||||||
ADR-0035 §13.)*
|
`do_add_index`/`do_drop_index`; `CREATE UNIQUE INDEX` admitted in
|
||||||
|
advanced mode via the `IndexSchema.unique` flag, ADR-0025 Amendment 1)).
|
||||||
|
Remaining DDL — `ALTER TABLE` (4e–4h) — is phased per ADR-0035 §13.)*
|
||||||
- [ ] **Q2** Non-standard syntax rejected with a clear message
|
- [ ] **Q2** Non-standard syntax rejected with a clear message
|
||||||
pointing at the supported subset.
|
pointing at the supported subset.
|
||||||
*(Design done — ADR-0030 §8: out-of-subset statements are
|
*(Design done — ADR-0030 §8: out-of-subset statements are
|
||||||
|
|||||||
+27
@@ -469,6 +469,24 @@ impl App {
|
|||||||
));
|
));
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslDropIndexSkipped { command } => {
|
||||||
|
// No-op (DROP INDEX IF EXISTS on an absent index,
|
||||||
|
// ADR-0035 §4d): just the skip note. `target_table()`
|
||||||
|
// returns the index name for `SqlDropIndex`.
|
||||||
|
self.note_system(crate::t!(
|
||||||
|
"ddl.drop_index_skipped_absent",
|
||||||
|
name = command.target_table()
|
||||||
|
));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
AppEvent::DslCreateIndexSkipped { command: _, name } => {
|
||||||
|
// No-op (CREATE INDEX IF NOT EXISTS on an existing index
|
||||||
|
// name, ADR-0035 §4d): the skip note carries the resolved
|
||||||
|
// index name (the unnamed form's auto-name isn't on the
|
||||||
|
// command). No structure shown.
|
||||||
|
self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
AppEvent::DslDataSucceeded { command, data } => {
|
AppEvent::DslDataSucceeded { command, data } => {
|
||||||
self.handle_dsl_query_success(&command, &data);
|
self.handle_dsl_query_success(&command, &data);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -1603,6 +1621,12 @@ 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::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
|
||||||
|
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
|
||||||
|
// (it reuses `do_add_index`); route engine/validation errors
|
||||||
|
// through it with the parsed table.
|
||||||
|
C::SqlCreateIndex { table, .. } => {
|
||||||
|
(Operation::AddIndex, Some(table.as_str()), None)
|
||||||
|
}
|
||||||
C::AddConstraint { table, column, .. } => (
|
C::AddConstraint { table, column, .. } => (
|
||||||
Operation::AddConstraint,
|
Operation::AddConstraint,
|
||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
@@ -1619,6 +1643,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
|
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
|
||||||
},
|
},
|
||||||
|
// The SQL `DROP INDEX` is name-only (the table is resolved by
|
||||||
|
// the executor), like the named DSL drop.
|
||||||
|
C::SqlDropIndex { .. } => (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),
|
||||||
|
|||||||
+13
-3
@@ -48,9 +48,19 @@ pub struct SchemaCache {
|
|||||||
/// 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
|
/// Per-table user indexes (ADR-0025). Keyed by table name; drives
|
||||||
/// name; drives the nested tables/indexes items panel (S2).
|
/// the nested tables/indexes items panel (S2). Each entry carries
|
||||||
pub table_indexes: std::collections::HashMap<String, Vec<String>>,
|
/// the index's uniqueness so the panel can mark a UNIQUE index
|
||||||
|
/// (ADR-0035 §4d).
|
||||||
|
pub table_indexes: std::collections::HashMap<String, Vec<IndexEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One per-table index for the items panel (ADR-0025 / ADR-0035 §4d):
|
||||||
|
/// its name and whether it is a UNIQUE index.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct IndexEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub unique: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One column's user-facing type info, scoped to a table
|
/// One column's user-facing type info, scoped to a table
|
||||||
|
|||||||
@@ -486,6 +486,28 @@ enum Request {
|
|||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
|
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
|
||||||
},
|
},
|
||||||
|
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4d).
|
||||||
|
/// Executes through `do_drop_index`; `if_exists` turns an absent
|
||||||
|
/// index into a no-op (`DropIndexOutcome::Skipped`, no snapshot).
|
||||||
|
SqlDropIndex {
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<Result<DropIndexOutcome, DbError>>,
|
||||||
|
},
|
||||||
|
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
|
||||||
|
/// (ADR-0035 §4d). Executes through `do_add_index` (with `unique`);
|
||||||
|
/// `if_not_exists` turns an existing index name into a no-op
|
||||||
|
/// (`CreateIndexOutcome::Skipped`, no snapshot).
|
||||||
|
SqlCreateIndex {
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
unique: bool,
|
||||||
|
if_not_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<Result<CreateIndexOutcome, DbError>>,
|
||||||
|
},
|
||||||
AddColumn {
|
AddColumn {
|
||||||
table: String,
|
table: String,
|
||||||
column: ColumnSpec,
|
column: ColumnSpec,
|
||||||
@@ -894,6 +916,53 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advanced-mode SQL `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
|
||||||
|
/// Returns whether the index was dropped (with the affected table's
|
||||||
|
/// structure) or skipped (the `IF EXISTS` no-op on an absent index).
|
||||||
|
pub async fn sql_drop_index(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Result<DropIndexOutcome, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::SqlDropIndex {
|
||||||
|
name,
|
||||||
|
if_exists,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
|
||||||
|
/// (ADR-0035 §4d). Returns whether the index was created (with the
|
||||||
|
/// affected table's structure) or skipped (the `IF NOT EXISTS` no-op
|
||||||
|
/// on an existing index name, carrying the resolved name).
|
||||||
|
pub async fn sql_create_index(
|
||||||
|
&self,
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
unique: bool,
|
||||||
|
if_not_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Result<CreateIndexOutcome, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::SqlCreateIndex {
|
||||||
|
name,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
unique,
|
||||||
|
if_not_exists,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_column(
|
pub async fn add_column(
|
||||||
&self,
|
&self,
|
||||||
table: String,
|
table: String,
|
||||||
@@ -1950,6 +2019,10 @@ fn handle_request(
|
|||||||
name.as_deref(),
|
name.as_deref(),
|
||||||
&table,
|
&table,
|
||||||
&columns,
|
&columns,
|
||||||
|
// Simple-mode `add index` is always non-unique
|
||||||
|
// (ADR-0025); `add unique index` stays deferred. The SQL
|
||||||
|
// `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d).
|
||||||
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Request::DropIndex {
|
Request::DropIndex {
|
||||||
@@ -1964,6 +2037,76 @@ fn handle_request(
|
|||||||
&selector,
|
&selector,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Request::SqlDropIndex {
|
||||||
|
name,
|
||||||
|
if_exists,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
} => {
|
||||||
|
// `IF EXISTS` on an absent index is a no-op: reply `Skipped`
|
||||||
|
// and take **no** snapshot (nothing to undo). The submitted
|
||||||
|
// line is still journalled (the 4c skip pattern, ADR-0034 /
|
||||||
|
// ADR-0035 §4). Existence uses the same user-index lookup as
|
||||||
|
// `do_drop_index` (`sql IS NOT NULL`).
|
||||||
|
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
|
||||||
|
let result = (|| {
|
||||||
|
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||||
|
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||||
|
}
|
||||||
|
Ok(DropIndexOutcome::Skipped)
|
||||||
|
})();
|
||||||
|
let _ = reply.send(result);
|
||||||
|
} else {
|
||||||
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||||
|
do_drop_index(
|
||||||
|
conn,
|
||||||
|
persistence,
|
||||||
|
source.as_deref(),
|
||||||
|
&IndexSelector::Named { name: name.clone() },
|
||||||
|
)
|
||||||
|
.map(DropIndexOutcome::Dropped)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::SqlCreateIndex {
|
||||||
|
name,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
unique,
|
||||||
|
if_not_exists,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
} => {
|
||||||
|
// `IF NOT EXISTS` short-circuits only a *name* collision into
|
||||||
|
// a no-op (reply `Skipped`, no snapshot, line journalled — the
|
||||||
|
// 4c skip pattern). The name uses the broad lookup (any index
|
||||||
|
// of that name), matching `do_add_index`'s collision guard.
|
||||||
|
// A *different*-named but redundant-column-set create still
|
||||||
|
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
|
||||||
|
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
|
||||||
|
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
|
||||||
|
let result = (|| {
|
||||||
|
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||||
|
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||||
|
}
|
||||||
|
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
|
||||||
|
})();
|
||||||
|
let _ = reply.send(result);
|
||||||
|
} else {
|
||||||
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||||
|
do_add_index(
|
||||||
|
conn,
|
||||||
|
persistence,
|
||||||
|
source.as_deref(),
|
||||||
|
name.as_deref(),
|
||||||
|
&table,
|
||||||
|
&columns,
|
||||||
|
unique,
|
||||||
|
)
|
||||||
|
.map(CreateIndexOutcome::Created)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Request::AddConstraint {
|
Request::AddConstraint {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
@@ -2415,6 +2558,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
|||||||
name: idx.name,
|
name: idx.name,
|
||||||
table: name.clone(),
|
table: name.clone(),
|
||||||
columns: idx.columns,
|
columns: idx.columns,
|
||||||
|
unique: idx.unique,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2699,6 +2843,34 @@ pub enum DropOutcome {
|
|||||||
Skipped,
|
Skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The result of an advanced-mode SQL `DROP INDEX` (ADR-0035 §4d).
|
||||||
|
///
|
||||||
|
/// Either the index was dropped — `Dropped` carries the affected
|
||||||
|
/// table's structure so the runtime auto-shows the now de-indexed table
|
||||||
|
/// (ADR-0014), unlike `DROP TABLE` whose table is gone — or `IF EXISTS`
|
||||||
|
/// matched no index and the statement was a no-op driving the "doesn't
|
||||||
|
/// exist — skipped" note (the index name comes from the command).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DropIndexOutcome {
|
||||||
|
Dropped(TableDescription),
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of an advanced-mode SQL `CREATE [UNIQUE] INDEX`
|
||||||
|
/// (ADR-0035 §4d).
|
||||||
|
///
|
||||||
|
/// Either the index was created — `Created` carries the affected table's
|
||||||
|
/// structure for the auto-show (ADR-0014) — or `IF NOT EXISTS` matched
|
||||||
|
/// an existing index name and the statement was a no-op. `Skipped`
|
||||||
|
/// carries the **resolved** index name (the auto-name is unknown to the
|
||||||
|
/// command for the unnamed form) to drive the "already exists — skipped"
|
||||||
|
/// note.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CreateIndexOutcome {
|
||||||
|
Created(TableDescription),
|
||||||
|
Skipped(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn do_create_table(
|
fn do_create_table(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -5859,6 +6031,38 @@ fn do_drop_relationship(
|
|||||||
/// Refuses a redundant index on an already-indexed column set
|
/// Refuses a redundant index on an already-indexed column set
|
||||||
/// and a name collision. The index name is auto-generated as
|
/// and a name collision. The index name is auto-generated as
|
||||||
/// `<table>_<col…>_idx` when not supplied.
|
/// `<table>_<col…>_idx` when not supplied.
|
||||||
|
/// Resolve an index name: the user-given name, or the ADR-0025
|
||||||
|
/// auto-name `<table>_<col…>_idx`. Shared by `do_add_index` and the
|
||||||
|
/// `CREATE INDEX IF NOT EXISTS` skip pre-check (ADR-0035 §4d) so both
|
||||||
|
/// compute the same name.
|
||||||
|
fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> String {
|
||||||
|
name.map_or_else(
|
||||||
|
|| format!("{table}_{}_idx", columns.join("_")),
|
||||||
|
ToString::to_string,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
|
||||||
|
///
|
||||||
|
/// `user_only = true` counts only explicit `CREATE INDEX` objects
|
||||||
|
/// (`sql IS NOT NULL`), matching `do_drop_index`'s named lookup — used
|
||||||
|
/// by the `DROP INDEX IF EXISTS` skip. `user_only = false` counts any
|
||||||
|
/// index of that name (incl. the automatic PK / UNIQUE-constraint
|
||||||
|
/// indexes), matching `do_add_index`'s name-collision guard — used by
|
||||||
|
/// the `CREATE INDEX IF NOT EXISTS` skip.
|
||||||
|
fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result<bool, DbError> {
|
||||||
|
let sql = if user_only {
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master \
|
||||||
|
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;"
|
||||||
|
} else {
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1;"
|
||||||
|
};
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(sql, [name], |row| row.get(0))
|
||||||
|
.map_err(DbError::from_rusqlite)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
fn do_add_index(
|
fn do_add_index(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
persistence: Option<&Persistence>,
|
||||||
@@ -5866,7 +6070,21 @@ fn do_add_index(
|
|||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
table: &str,
|
table: &str,
|
||||||
columns: &[String],
|
columns: &[String],
|
||||||
|
unique: bool,
|
||||||
) -> Result<TableDescription, DbError> {
|
) -> Result<TableDescription, DbError> {
|
||||||
|
// 0. Internal `__rdbms_*` tables are not user tables (they are
|
||||||
|
// filtered from `list_tables` and never offered in completion), so
|
||||||
|
// indexing one is refused as "no such table" — the same opacity
|
||||||
|
// the rest of the app presents. Guards BOTH the simple `add index`
|
||||||
|
// and the SQL `CREATE INDEX` surfaces, since both reach here
|
||||||
|
// (ADR-0025 / ADR-0035 §4d; the grammar's `reject_internal_table`
|
||||||
|
// only covers the typed SQL family, not the simple node).
|
||||||
|
if table.to_ascii_lowercase().starts_with("__rdbms_") {
|
||||||
|
return Err(DbError::Sqlite {
|
||||||
|
message: format!("no such table: {table}"),
|
||||||
|
kind: SqliteErrorKind::NoSuchTable,
|
||||||
|
});
|
||||||
|
}
|
||||||
// 1. Table must exist; gather its columns.
|
// 1. Table must exist; gather its columns.
|
||||||
let schema = read_schema(conn, table)?;
|
let schema = read_schema(conn, table)?;
|
||||||
// 2. Every indexed column must exist on the table.
|
// 2. Every indexed column must exist on the table.
|
||||||
@@ -5878,11 +6096,17 @@ fn do_add_index(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3. Refuse a redundant index over an identical column set.
|
// 3. Refuse a redundant index over an identical column set *of the
|
||||||
|
// same kind*. A plain and a unique index over the same columns are
|
||||||
|
// NOT redundant (the unique one enforces a constraint the plain one
|
||||||
|
// does not), so the guard keys on `(columns, unique)` (ADR-0035
|
||||||
|
// §4d). To hold both, the user must name them distinctly — the
|
||||||
|
// auto-name is identical, so the name guard (step 5) would
|
||||||
|
// otherwise collide.
|
||||||
let existing = read_table_indexes(conn, table)?;
|
let existing = read_table_indexes(conn, table)?;
|
||||||
if let Some(dup) = existing
|
if let Some(dup) = existing
|
||||||
.iter()
|
.iter()
|
||||||
.find(|i| i.columns.as_slice() == columns)
|
.find(|i| i.columns.as_slice() == columns && i.unique == unique)
|
||||||
{
|
{
|
||||||
return Err(DbError::Unsupported(format!(
|
return Err(DbError::Unsupported(format!(
|
||||||
"the columns ({}) of `{table}` are already indexed by `{}`.",
|
"the columns ({}) of `{table}` are already indexed by `{}`.",
|
||||||
@@ -5891,10 +6115,7 @@ fn do_add_index(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
// 4. Resolve the index name (auto-generate when omitted).
|
// 4. Resolve the index name (auto-generate when omitted).
|
||||||
let resolved = name.map_or_else(
|
let resolved = resolve_index_name(name, table, columns);
|
||||||
|| format!("{table}_{}_idx", columns.join("_")),
|
|
||||||
ToString::to_string,
|
|
||||||
);
|
|
||||||
// 5. Refuse a name collision.
|
// 5. Refuse a name collision.
|
||||||
let name_taken: i64 = conn
|
let name_taken: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -5919,13 +6140,14 @@ fn do_add_index(
|
|||||||
.map(|c| quote_ident(c))
|
.map(|c| quote_ident(c))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
let unique_kw = if unique { "UNIQUE " } else { "" };
|
||||||
let ddl = format!(
|
let ddl = format!(
|
||||||
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
|
||||||
idx = quote_ident(&resolved),
|
idx = quote_ident(&resolved),
|
||||||
tbl = quote_ident(table),
|
tbl = quote_ident(table),
|
||||||
cols = cols_csv,
|
cols = cols_csv,
|
||||||
);
|
);
|
||||||
debug!(ddl = %ddl, "add_index");
|
debug!(ddl = %ddl, unique, "add_index");
|
||||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||||
let description = do_describe_table(conn, table)?;
|
let description = do_describe_table(conn, table)?;
|
||||||
let changes = Changes {
|
let changes = Changes {
|
||||||
@@ -7818,8 +8040,12 @@ fn do_rebuild_from_text(
|
|||||||
.map(|c| quote_ident(c))
|
.map(|c| quote_ident(c))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
// ADR-0035 §4d: a UNIQUE index round-trips its uniqueness, so
|
||||||
|
// re-emit `CREATE UNIQUE INDEX` — otherwise a rebuild would
|
||||||
|
// silently demote it to a plain index.
|
||||||
|
let unique_kw = if index.unique { "UNIQUE " } else { "" };
|
||||||
tx.execute_batch(&format!(
|
tx.execute_batch(&format!(
|
||||||
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
|
||||||
idx = quote_ident(&index.name),
|
idx = quote_ident(&index.name),
|
||||||
tbl = quote_ident(&index.table),
|
tbl = quote_ident(&index.table),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -278,6 +278,29 @@ pub enum Command {
|
|||||||
DropIndex {
|
DropIndex {
|
||||||
selector: IndexSelector,
|
selector: IndexSelector,
|
||||||
},
|
},
|
||||||
|
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4,
|
||||||
|
/// sub-phase 4d). Name-only (SQL has no positional column form — that
|
||||||
|
/// is the simple `drop index on T(…)`). Executes through the same
|
||||||
|
/// `do_drop_index` machinery as [`Self::DropIndex`]; `if_exists`
|
||||||
|
/// turns an absent index into a no-op-with-note rather than an error.
|
||||||
|
SqlDropIndex {
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
},
|
||||||
|
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>]
|
||||||
|
/// ON <table> (<col>, …)` (ADR-0035 §4d). Executes through the same
|
||||||
|
/// `do_add_index` machinery as [`Self::AddIndex`] (the columns/
|
||||||
|
/// auto-name reuse), plus the `unique` flag (simple mode has no
|
||||||
|
/// `add unique index` — that stays deferred per ADR-0025). `name` is
|
||||||
|
/// `None` for the unnamed form (auto-named at execution);
|
||||||
|
/// `if_not_exists` makes an existing index name a no-op-with-note.
|
||||||
|
SqlCreateIndex {
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
unique: bool,
|
||||||
|
if_not_exists: bool,
|
||||||
|
},
|
||||||
/// Add a column-level constraint to an existing column
|
/// Add a column-level constraint to an existing column
|
||||||
/// (ADR-0029 §2.2). Applied through the rebuild-table
|
/// (ADR-0029 §2.2). Applied through the rebuild-table
|
||||||
/// primitive after a §5 dry-run guards populated columns.
|
/// primitive after a §5 dry-run guards populated columns.
|
||||||
@@ -710,6 +733,8 @@ impl Command {
|
|||||||
Self::DropRelationship { .. } => "drop relationship",
|
Self::DropRelationship { .. } => "drop relationship",
|
||||||
Self::AddIndex { .. } => "add index",
|
Self::AddIndex { .. } => "add index",
|
||||||
Self::DropIndex { .. } => "drop index",
|
Self::DropIndex { .. } => "drop index",
|
||||||
|
Self::SqlDropIndex { .. } => "drop index",
|
||||||
|
Self::SqlCreateIndex { .. } => "create index",
|
||||||
Self::AddConstraint { .. } => "add constraint",
|
Self::AddConstraint { .. } => "add constraint",
|
||||||
Self::DropConstraint { .. } => "drop constraint",
|
Self::DropConstraint { .. } => "drop constraint",
|
||||||
Self::ShowTable { .. } => "show table",
|
Self::ShowTable { .. } => "show table",
|
||||||
@@ -783,6 +808,11 @@ impl Command {
|
|||||||
// sensible fallback for logging.
|
// sensible fallback for logging.
|
||||||
IndexSelector::Named { name } => name,
|
IndexSelector::Named { name } => name,
|
||||||
},
|
},
|
||||||
|
// The SQL drop is name-only; the index name identifies it
|
||||||
|
// until the executor resolves the table (mirrors the named
|
||||||
|
// `DropIndex` / `SqlDropTable` fallback).
|
||||||
|
Self::SqlDropIndex { name, .. } => name,
|
||||||
|
Self::SqlCreateIndex { table, .. } => table,
|
||||||
// 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,
|
||||||
|
|||||||
@@ -203,6 +203,21 @@ static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
|
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
|
||||||
|
|
||||||
|
// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||||||
|
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
|
||||||
|
// form (that stays the simple `drop index on …`, which falls back to
|
||||||
|
// the simple `drop` node). Leads on the concrete `index` keyword; the
|
||||||
|
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
|
||||||
|
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
|
||||||
|
// still parses and reaches the skip path.
|
||||||
|
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("index")),
|
||||||
|
SQL_DROP_IF_EXISTS_OPT,
|
||||||
|
INDEX_NAME_EXISTING,
|
||||||
|
Node::Optional(&Node::Punct(';')),
|
||||||
|
];
|
||||||
|
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// drop_column — `drop column [from] [table] <T> : <col>`
|
// drop_column — `drop column [from] [table] <T> : <col>`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1726,6 +1741,106 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
|||||||
usage_ids: &["parse.usage.sql_drop_table"],
|
usage_ids: &["parse.usage.sql_drop_table"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
|
||||||
|
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
|
||||||
|
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
|
||||||
|
/// flag (mirroring `build_sql_drop_table`).
|
||||||
|
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
|
Ok(Command::SqlDropIndex {
|
||||||
|
name: require_ident(path, "index_name")?,
|
||||||
|
if_exists: path.contains_word("if"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("drop"),
|
||||||
|
shape: SQL_DROP_INDEX_SHAPE,
|
||||||
|
ast_builder: build_sql_drop_index,
|
||||||
|
help_id: Some("ddl.sql_drop_index"),
|
||||||
|
usage_ids: &["parse.usage.sql_drop_index"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
|
||||||
|
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
|
||||||
|
// node (alongside SQL_CREATE_TABLE).
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
|
||||||
|
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
||||||
|
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
||||||
|
// builder reads `unique` presence via `contains_word("unique")`.
|
||||||
|
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
|
||||||
|
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
|
||||||
|
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
||||||
|
static SQL_CI_LEAD_CHOICES: &[Node] =
|
||||||
|
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||||||
|
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
||||||
|
|
||||||
|
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("if")),
|
||||||
|
Node::Word(Word::keyword("not")),
|
||||||
|
Node::Word(Word::keyword("exists")),
|
||||||
|
];
|
||||||
|
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));
|
||||||
|
|
||||||
|
// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
|
||||||
|
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
|
||||||
|
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
|
||||||
|
// instead greedily consume the `on` keyword (`consume_ident` does not
|
||||||
|
// reject keywords), breaking the unnamed form.
|
||||||
|
static SQL_CI_UNNAMED_NODES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("on")),
|
||||||
|
TABLE_NAME_EXISTING,
|
||||||
|
Node::Punct('('),
|
||||||
|
INDEX_COLUMN_LIST,
|
||||||
|
Node::Punct(')'),
|
||||||
|
];
|
||||||
|
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
|
||||||
|
static SQL_CI_NAMED_NODES: &[Node] = &[
|
||||||
|
INDEX_NAME_NEW,
|
||||||
|
Node::Word(Word::keyword("on")),
|
||||||
|
TABLE_NAME_EXISTING,
|
||||||
|
Node::Punct('('),
|
||||||
|
INDEX_COLUMN_LIST,
|
||||||
|
Node::Punct(')'),
|
||||||
|
];
|
||||||
|
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
|
||||||
|
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
|
||||||
|
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);
|
||||||
|
|
||||||
|
static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
|
||||||
|
SQL_CI_LEAD,
|
||||||
|
SQL_CI_IF_NOT_EXISTS_OPT,
|
||||||
|
SQL_CI_SELECTOR,
|
||||||
|
Node::Optional(&Node::Punct(';')),
|
||||||
|
];
|
||||||
|
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);
|
||||||
|
|
||||||
|
/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
|
||||||
|
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
|
||||||
|
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
|
||||||
|
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
|
||||||
|
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
|
||||||
|
/// extraction mirrors the simple `add index` builder.
|
||||||
|
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
|
Ok(Command::SqlCreateIndex {
|
||||||
|
name: ident(path, "index_name").map(str::to_string),
|
||||||
|
table: require_ident(path, "table_name")?,
|
||||||
|
columns: collect_idents(path, "column_name"),
|
||||||
|
unique: path.contains_word("unique"),
|
||||||
|
if_not_exists: path.contains_word("if"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("create"),
|
||||||
|
shape: SQL_CREATE_INDEX_SHAPE,
|
||||||
|
ast_builder: build_sql_create_index,
|
||||||
|
help_id: Some("ddl.sql_create_index"),
|
||||||
|
usage_ids: &["parse.usage.sql_create_index"],
|
||||||
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1994,3 +2109,187 @@ mod sql_drop_table_tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sql_drop_index_tests {
|
||||||
|
use crate::dsl::command::{Command, IndexSelector};
|
||||||
|
use crate::dsl::parser::parse_command_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
fn drop_index_fields(input: &str) -> (String, bool) {
|
||||||
|
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||||
|
Command::SqlDropIndex { name, if_exists } => (name, if_exists),
|
||||||
|
other => panic!("expected SqlDropIndex, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
|
||||||
|
let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
|
||||||
|
assert_eq!(name, "Orders_CustId_idx");
|
||||||
|
assert!(!if_exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_exists_sets_the_flag() {
|
||||||
|
let (name, if_exists) = drop_index_fields("drop index if exists ix");
|
||||||
|
assert_eq!(name, "ix");
|
||||||
|
assert!(if_exists);
|
||||||
|
// trailing semicolon tolerated
|
||||||
|
assert!(drop_index_fields("drop index if exists ix;").1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
|
||||||
|
// `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
|
||||||
|
// SQL_DROP_INDEX); the dispatcher must try both and pick the
|
||||||
|
// shape that matches (ADR-0035 §4d — the second-advanced-node
|
||||||
|
// case).
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
|
||||||
|
Command::SqlDropTable { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
|
||||||
|
Command::SqlDropIndex { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
|
||||||
|
// The SQL form is name-only; `drop index on T (cols)` is the
|
||||||
|
// simple positional form. The name-only SQL shape can't fully
|
||||||
|
// match it (trailing `(cols)`), so it falls back to the simple
|
||||||
|
// `drop` node's `DropIndex { Columns }` even in advanced mode.
|
||||||
|
match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
|
||||||
|
.expect("parses")
|
||||||
|
{
|
||||||
|
Command::DropIndex {
|
||||||
|
selector: IndexSelector::Columns { table, columns },
|
||||||
|
} => {
|
||||||
|
assert_eq!(table, "Orders");
|
||||||
|
assert_eq!(columns, vec!["CustId".to_string()]);
|
||||||
|
}
|
||||||
|
other => panic!("expected positional DropIndex, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_drop_index_in_simple_mode_is_the_dsl_command() {
|
||||||
|
// In simple mode the SQL node is gated; `drop index ix` is the
|
||||||
|
// simple `DropIndex { Named }`.
|
||||||
|
match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
|
||||||
|
Command::DropIndex {
|
||||||
|
selector: IndexSelector::Named { name },
|
||||||
|
} => assert_eq!(name, "ix"),
|
||||||
|
other => panic!("expected named DropIndex, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sql_create_index_tests {
|
||||||
|
use crate::dsl::command::Command;
|
||||||
|
use crate::dsl::parser::parse_command_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
struct Ci {
|
||||||
|
name: Option<String>,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
unique: bool,
|
||||||
|
if_not_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ci(input: &str) -> Ci {
|
||||||
|
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||||
|
Command::SqlCreateIndex {
|
||||||
|
name,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
unique,
|
||||||
|
if_not_exists,
|
||||||
|
} => Ci { name, table, columns, unique, if_not_exists },
|
||||||
|
other => panic!("expected SqlCreateIndex, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_create_index_parses() {
|
||||||
|
let c = ci("create index ix on Customers (email)");
|
||||||
|
assert_eq!(c.name.as_deref(), Some("ix"));
|
||||||
|
assert_eq!(c.table, "Customers");
|
||||||
|
assert_eq!(c.columns, vec!["email".to_string()]);
|
||||||
|
assert!(!c.unique);
|
||||||
|
assert!(!c.if_not_exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_create_index_leaves_name_none() {
|
||||||
|
// The unnamed form: the optional name must NOT swallow `on`
|
||||||
|
// (the `DI_SELECTOR`-style on-led-first selector handles it).
|
||||||
|
let c = ci("create index on Customers (email)");
|
||||||
|
assert_eq!(c.name, None);
|
||||||
|
assert_eq!(c.table, "Customers");
|
||||||
|
assert_eq!(c.columns, vec!["email".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_sets_the_flag() {
|
||||||
|
let c = ci("create unique index ux on Customers (email)");
|
||||||
|
assert!(c.unique);
|
||||||
|
assert_eq!(c.name.as_deref(), Some("ux"));
|
||||||
|
// unnamed unique form too
|
||||||
|
let c2 = ci("create unique index on Customers (email)");
|
||||||
|
assert!(c2.unique);
|
||||||
|
assert_eq!(c2.name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_not_exists_sets_the_flag() {
|
||||||
|
let c = ci("create index if not exists ix on Customers (email)");
|
||||||
|
assert!(c.if_not_exists);
|
||||||
|
assert_eq!(c.name.as_deref(), Some("ix"));
|
||||||
|
// combined with unique + unnamed + trailing semicolon
|
||||||
|
let c2 = ci("create unique index if not exists on Customers (email);");
|
||||||
|
assert!(c2.unique && c2.if_not_exists);
|
||||||
|
assert_eq!(c2.name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_column_index_parses() {
|
||||||
|
let c = ci("create index on Orders (CustId, Date)");
|
||||||
|
assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
|
||||||
|
// `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
|
||||||
|
// SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
|
||||||
|
.expect("parses"),
|
||||||
|
Command::SqlCreateTable { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
|
||||||
|
Command::SqlCreateIndex { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
|
||||||
|
.expect("parses"),
|
||||||
|
Command::SqlCreateIndex { unique: true, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_create_table_dsl_still_parses_in_advanced_mode() {
|
||||||
|
// The `create table … with pk …` DSL form falls back to the
|
||||||
|
// simple node even with two advanced `create` nodes present.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
|
||||||
|
.expect("parses"),
|
||||||
|
Command::CreateTable { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-8
@@ -585,16 +585,24 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
|||||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||||
(&data::SQL_DELETE, CommandCategory::Advanced),
|
(&data::SQL_DELETE, CommandCategory::Advanced),
|
||||||
// Shared entry word `create` (ADR-0035 §2): the simple
|
// Shared entry word `create` (ADR-0035 §2): the simple
|
||||||
// `ddl::CREATE` (above) and this advanced SQL node. The
|
// `ddl::CREATE` (above) and these advanced SQL nodes. The
|
||||||
// dispatcher tries SQL first in advanced mode and falls back to
|
// dispatcher tries the advanced candidates first in advanced mode
|
||||||
// the `create table … with pk …` DSL node when the SQL shape
|
// and falls back to the `create table … with pk …` DSL node when no
|
||||||
// does not match — the `insert` precedent.
|
// SQL shape matches — the `insert` precedent. 4d adds
|
||||||
|
// SQL_CREATE_INDEX, so `create` now has *two* advanced nodes;
|
||||||
|
// `decide` tries both (`create table …` → SQL_CREATE_TABLE,
|
||||||
|
// `create [unique] index …` → SQL_CREATE_INDEX).
|
||||||
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
||||||
// Shared `drop` entry word: `ddl::DROP` (simple) and this advanced
|
(&ddl::SQL_CREATE_INDEX, CommandCategory::Advanced),
|
||||||
// SQL node. SQL-first in advanced mode; `drop table [if exists] T`
|
// Shared `drop` entry word: `ddl::DROP` (simple) and these advanced
|
||||||
// matches here while `drop column`/`drop relationship`/`drop index`
|
// SQL nodes. SQL-first in advanced mode; `drop table [if exists] T`
|
||||||
// fall back to the simple `drop` node.
|
// → SQL_DROP_TABLE, `drop index [if exists] <name>` → SQL_DROP_INDEX
|
||||||
|
// (4d — `drop` now has *two* advanced nodes; the dispatcher's
|
||||||
|
// `decide` tries all advanced candidates). `drop column`/`drop
|
||||||
|
// relationship`/`drop index on T(…)` fall back to the simple `drop`
|
||||||
|
// node.
|
||||||
(&ddl::SQL_DROP_TABLE, CommandCategory::Advanced),
|
(&ddl::SQL_DROP_TABLE, CommandCategory::Advanced),
|
||||||
|
(&ddl::SQL_DROP_INDEX, CommandCategory::Advanced),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ pub enum AppEvent {
|
|||||||
DslDropSkipped {
|
DslDropSkipped {
|
||||||
command: Command,
|
command: Command,
|
||||||
},
|
},
|
||||||
|
/// A SQL `DROP INDEX IF EXISTS` matched no index — a no-op
|
||||||
|
/// (ADR-0035 §4d). Renders an index-specific "doesn't exist —
|
||||||
|
/// skipped" note; no structure to show.
|
||||||
|
DslDropIndexSkipped {
|
||||||
|
command: Command,
|
||||||
|
},
|
||||||
|
/// A SQL `CREATE INDEX IF NOT EXISTS` matched an existing index name
|
||||||
|
/// — a no-op (ADR-0035 §4d). `name` is the resolved index name (the
|
||||||
|
/// auto-name is not on the command). Renders "already exists —
|
||||||
|
/// skipped"; no structure to show.
|
||||||
|
DslCreateIndexSkipped {
|
||||||
|
command: Command,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
/// A `show data` query succeeded.
|
/// A `show data` query succeeded.
|
||||||
DslDataSucceeded { command: Command, data: DataResult },
|
DslDataSucceeded { command: Command, data: DataResult },
|
||||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||||
|
|||||||
@@ -173,9 +173,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.ddl.create", &[]),
|
("help.ddl.create", &[]),
|
||||||
("help.ddl.sql_create_table", &[]),
|
("help.ddl.sql_create_table", &[]),
|
||||||
("help.ddl.sql_drop_table", &[]),
|
("help.ddl.sql_drop_table", &[]),
|
||||||
|
("help.ddl.sql_create_index", &[]),
|
||||||
|
("help.ddl.sql_drop_index", &[]),
|
||||||
// Advanced-mode SQL CREATE TABLE / DROP TABLE no-op notes (ADR-0035 §4).
|
// Advanced-mode SQL CREATE TABLE / DROP TABLE no-op notes (ADR-0035 §4).
|
||||||
("ddl.create_skipped_exists", &["name"]),
|
("ddl.create_skipped_exists", &["name"]),
|
||||||
("ddl.drop_skipped_absent", &["name"]),
|
("ddl.drop_skipped_absent", &["name"]),
|
||||||
|
// Advanced-mode SQL CREATE INDEX / DROP INDEX no-op notes (ADR-0035 §4d).
|
||||||
|
("ddl.create_index_skipped_exists", &["name"]),
|
||||||
|
("ddl.drop_index_skipped_absent", &["name"]),
|
||||||
("help.ddl.drop", &[]),
|
("help.ddl.drop", &[]),
|
||||||
("help.ddl.add", &[]),
|
("help.ddl.add", &[]),
|
||||||
("help.ddl.rename", &[]),
|
("help.ddl.rename", &[]),
|
||||||
@@ -248,6 +253,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.create_table", &[]),
|
("parse.usage.create_table", &[]),
|
||||||
("parse.usage.sql_create_table", &[]),
|
("parse.usage.sql_create_table", &[]),
|
||||||
("parse.usage.sql_drop_table", &[]),
|
("parse.usage.sql_drop_table", &[]),
|
||||||
|
("parse.usage.sql_create_index", &[]),
|
||||||
|
("parse.usage.sql_drop_index", &[]),
|
||||||
("parse.usage.delete", &[]),
|
("parse.usage.delete", &[]),
|
||||||
("parse.usage.drop_column", &[]),
|
("parse.usage.drop_column", &[]),
|
||||||
("parse.usage.drop_constraint", &[]),
|
("parse.usage.drop_constraint", &[]),
|
||||||
|
|||||||
@@ -265,6 +265,11 @@ help:
|
|||||||
[, primary key (<col>, ...)]) — create a table (advanced SQL)
|
[, primary key (<col>, ...)]) — create a table (advanced SQL)
|
||||||
sql_drop_table: |-
|
sql_drop_table: |-
|
||||||
drop table [if exists] <T> — remove a table (advanced SQL)
|
drop table [if exists] <T> — remove a table (advanced SQL)
|
||||||
|
sql_create_index: |-
|
||||||
|
create [unique] index [if not exists] [<name>] on <T> (<col>, ...)
|
||||||
|
— create an index (advanced SQL)
|
||||||
|
sql_drop_index: |-
|
||||||
|
drop index [if exists] <name> — remove an index (advanced SQL)
|
||||||
drop: |-
|
drop: |-
|
||||||
drop table <T> — remove a table
|
drop table <T> — remove a table
|
||||||
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
||||||
@@ -382,6 +387,12 @@ ddl:
|
|||||||
# `drop table if exists <T>` where the table is absent: a no-op that
|
# `drop table if exists <T>` where the table is absent: a no-op that
|
||||||
# succeeds with this note instead of a "doesn't exist" error.
|
# succeeds with this note instead of a "doesn't exist" error.
|
||||||
drop_skipped_absent: "table '{name}' doesn't exist — skipped (no changes made)"
|
drop_skipped_absent: "table '{name}' doesn't exist — skipped (no changes made)"
|
||||||
|
# `create [unique] index if not exists <name> …` where the index name
|
||||||
|
# already exists: a no-op that succeeds with this note (ADR-0035 §4d).
|
||||||
|
create_index_skipped_exists: "index '{name}' already exists — skipped (no changes made)"
|
||||||
|
# `drop index if exists <name>` where the index is absent: a no-op that
|
||||||
|
# succeeds with this note instead of a "doesn't exist" error.
|
||||||
|
drop_index_skipped_absent: "index '{name}' doesn't exist — skipped (no changes made)"
|
||||||
|
|
||||||
parse:
|
parse:
|
||||||
# Wrapper around chumsky's structural error message. The
|
# Wrapper around chumsky's structural error message. The
|
||||||
@@ -452,6 +463,8 @@ parse:
|
|||||||
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
|
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
|
||||||
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key], ... [, primary key (<col>, ...)])"
|
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key], ... [, primary key (<col>, ...)])"
|
||||||
sql_drop_table: "drop table [if exists] <Name>"
|
sql_drop_table: "drop table [if exists] <Name>"
|
||||||
|
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
|
||||||
|
sql_drop_index: "drop index [if exists] <Name>"
|
||||||
drop_table: "drop table <Name>"
|
drop_table: "drop table <Name>"
|
||||||
drop_column: "drop column [from] [table] <Table>: <Name>"
|
drop_column: "drop column [from] [table] <Table>: <Name>"
|
||||||
drop_relationship: |-
|
drop_relationship: |-
|
||||||
|
|||||||
+29
-2
@@ -137,12 +137,15 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Indexes section (ADR-0025), shown only when the table
|
// Indexes section (ADR-0025), shown only when the table
|
||||||
// carries at least one user-created index.
|
// carries at least one user-created index. A UNIQUE index is
|
||||||
|
// marked `[unique]` so a learner can tell a uniqueness-enforcing
|
||||||
|
// index from a performance-only one (ADR-0035 §4d).
|
||||||
if !desc.indexes.is_empty() {
|
if !desc.indexes.is_empty() {
|
||||||
out.push("Indexes:".to_string());
|
out.push("Indexes:".to_string());
|
||||||
for index in &desc.indexes {
|
for index in &desc.indexes {
|
||||||
|
let unique = if index.unique { " [unique]" } else { "" };
|
||||||
out.push(format!(
|
out.push(format!(
|
||||||
" {} ({})",
|
" {} ({}){unique}",
|
||||||
index.name,
|
index.name,
|
||||||
index.columns.join(", "),
|
index.columns.join(", "),
|
||||||
));
|
));
|
||||||
@@ -797,6 +800,30 @@ mod tests {
|
|||||||
let out = render_structure(&desc).join("\n");
|
let out = render_structure(&desc).join("\n");
|
||||||
assert!(out.contains("Indexes:"), "got:\n{out}");
|
assert!(out.contains("Indexes:"), "got:\n{out}");
|
||||||
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
|
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
|
||||||
|
// A plain index carries no uniqueness marker.
|
||||||
|
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_structure_marks_a_unique_index() {
|
||||||
|
// ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
|
||||||
|
// can tell it from a performance-only index.
|
||||||
|
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: "uidx_email".to_string(),
|
||||||
|
columns: vec!["Email".to_string()],
|
||||||
|
unique: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let out = render_structure(&desc).join("\n");
|
||||||
|
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -186,6 +186,14 @@ pub struct IndexSchema {
|
|||||||
pub table: String,
|
pub table: String,
|
||||||
/// The indexed columns, in index order.
|
/// The indexed columns, in index order.
|
||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
|
/// Whether this is a `UNIQUE` index (ADR-0035 §4d — advanced-mode
|
||||||
|
/// `CREATE UNIQUE INDEX`). The engine reports it via
|
||||||
|
/// `pragma_index_list`'s `unique` column, so it is read back rather
|
||||||
|
/// than stored in any `__rdbms_*` table; it is carried here so it
|
||||||
|
/// round-trips through `project.yaml` and survives `rebuild`.
|
||||||
|
/// Defaults to `false` when missing in older project files (the YAML
|
||||||
|
/// field is optional on read); `version` stays `1`.
|
||||||
|
pub unique: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ fn write_index(out: &mut String, index: &IndexSchema) {
|
|||||||
out.push_str("e_if_needed(col));
|
out.push_str("e_if_needed(col));
|
||||||
}
|
}
|
||||||
let _ = writeln!(out, "]");
|
let _ = writeln!(out, "]");
|
||||||
|
// Emit `unique` only when true (ADR-0035 §4d), matching the
|
||||||
|
// column-`unique` convention — keeps pre-unique-index project files
|
||||||
|
// byte-stable on a no-op round-trip.
|
||||||
|
if index.unique {
|
||||||
|
let _ = writeln!(out, " unique: true");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_table(out: &mut String, table: &TableSchema) {
|
fn write_table(out: &mut String, table: &TableSchema) {
|
||||||
@@ -300,6 +306,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
|||||||
name: i.name,
|
name: i.name,
|
||||||
table: i.table,
|
table: i.table,
|
||||||
columns: i.columns,
|
columns: i.columns,
|
||||||
|
unique: i.unique,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(SchemaSnapshot {
|
Ok(SchemaSnapshot {
|
||||||
@@ -434,6 +441,11 @@ struct RawIndex {
|
|||||||
name: String,
|
name: String,
|
||||||
table: String,
|
table: String,
|
||||||
columns: Vec<String>,
|
columns: Vec<String>,
|
||||||
|
/// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project
|
||||||
|
/// files written before unique indexes existed omit it and default
|
||||||
|
/// to `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
unique: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -479,6 +491,7 @@ mod tests {
|
|||||||
name: "Orders_CustId_idx".to_string(),
|
name: "Orders_CustId_idx".to_string(),
|
||||||
table: "Orders".to_string(),
|
table: "Orders".to_string(),
|
||||||
columns: vec!["CustId".to_string()],
|
columns: vec!["CustId".to_string()],
|
||||||
|
unique: false,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,6 +569,89 @@ mod tests {
|
|||||||
assert_eq!(parsed, original);
|
assert_eq!(parsed, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_index_round_trips_through_yaml() {
|
||||||
|
// ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize
|
||||||
|
// → parse cycle. A plain index emits no `unique` line; a unique
|
||||||
|
// index emits `unique: true`.
|
||||||
|
let snap = SchemaSnapshot {
|
||||||
|
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||||
|
tables: vec![TableSchema {
|
||||||
|
name: "Customers".to_string(),
|
||||||
|
primary_key: vec!["id".to_string()],
|
||||||
|
columns: vec![
|
||||||
|
ColumnSchema {
|
||||||
|
name: "id".to_string(),
|
||||||
|
user_type: Type::Serial,
|
||||||
|
unique: false,
|
||||||
|
not_null: false,
|
||||||
|
default: None,
|
||||||
|
check: None,
|
||||||
|
},
|
||||||
|
ColumnSchema {
|
||||||
|
name: "Email".to_string(),
|
||||||
|
user_type: Type::Text,
|
||||||
|
unique: false,
|
||||||
|
not_null: false,
|
||||||
|
default: None,
|
||||||
|
check: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unique_constraints: Vec::new(),
|
||||||
|
check_constraints: Vec::new(),
|
||||||
|
}],
|
||||||
|
relationships: Vec::new(),
|
||||||
|
indexes: vec![
|
||||||
|
IndexSchema {
|
||||||
|
name: "Customers_Email_uidx".to_string(),
|
||||||
|
table: "Customers".to_string(),
|
||||||
|
columns: vec!["Email".to_string()],
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
IndexSchema {
|
||||||
|
name: "Customers_id_idx".to_string(),
|
||||||
|
table: "Customers".to_string(),
|
||||||
|
columns: vec!["id".to_string()],
|
||||||
|
unique: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let body = serialize_schema(&snap);
|
||||||
|
// The unique index emits the flag; the plain one does not.
|
||||||
|
assert!(body.contains("unique: true"), "yaml:\n{body}");
|
||||||
|
assert_eq!(
|
||||||
|
body.matches("unique: true").count(),
|
||||||
|
1,
|
||||||
|
"only the unique index carries the flag:\n{body}"
|
||||||
|
);
|
||||||
|
let parsed = parse_schema(&body).expect("parse schema");
|
||||||
|
assert_eq!(parsed, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_without_unique_field_defaults_to_false() {
|
||||||
|
// Older project files (written before unique indexes) omit the
|
||||||
|
// `unique` field; the `#[serde(default)]` makes it `false`.
|
||||||
|
let body = "\
|
||||||
|
version: 1
|
||||||
|
project:
|
||||||
|
created_at: 2026-05-25T00:00:00Z
|
||||||
|
tables:
|
||||||
|
- name: Customers
|
||||||
|
primary_key: [id]
|
||||||
|
columns:
|
||||||
|
- { name: id, type: serial }
|
||||||
|
relationships: []
|
||||||
|
indexes:
|
||||||
|
- name: Customers_id_idx
|
||||||
|
table: Customers
|
||||||
|
columns: [id]
|
||||||
|
";
|
||||||
|
let parsed = parse_schema(body).expect("parse schema");
|
||||||
|
assert_eq!(parsed.indexes.len(), 1);
|
||||||
|
assert!(!parsed.indexes[0].unique);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn column_constraints_round_trip_through_yaml() {
|
fn column_constraints_round_trip_through_yaml() {
|
||||||
// NOT NULL / UNIQUE / DEFAULT survive a serialize →
|
// NOT NULL / UNIQUE / DEFAULT survive a serialize →
|
||||||
|
|||||||
+55
-8
@@ -29,9 +29,9 @@ use crate::action::Action;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::Args;
|
use crate::cli::Args;
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, CreateOutcome, DataResult, Database, DbError,
|
AddColumnResult, ChangeColumnTypeResult, CreateIndexOutcome, CreateOutcome, DataResult,
|
||||||
DeleteResult, DropColumnResult, DropOutcome, InsertResult, QueryPlan, TableDescription,
|
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
||||||
UpdateResult,
|
QueryPlan, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::{Command, ColumnSpec};
|
use crate::dsl::{Command, ColumnSpec};
|
||||||
use crate::dsl::walker::Severity;
|
use crate::dsl::walker::Severity;
|
||||||
@@ -1029,11 +1029,18 @@ 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,
|
// Per-table indexes for the items panel (S2, ADR-0025).
|
||||||
// ADR-0025). Captured before `desc.columns` is
|
// Carry uniqueness so the panel can mark a UNIQUE index
|
||||||
|
// (ADR-0035 §4d). Captured before `desc.columns` is
|
||||||
// consumed below.
|
// consumed below.
|
||||||
let index_names: Vec<String> =
|
let index_entries: Vec<crate::completion::IndexEntry> = desc
|
||||||
desc.indexes.iter().map(|i| i.name.clone()).collect();
|
.indexes
|
||||||
|
.iter()
|
||||||
|
.map(|i| crate::completion::IndexEntry {
|
||||||
|
name: i.name.clone(),
|
||||||
|
unique: i.unique,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let cols: Vec<TableColumn> = desc
|
let cols: Vec<TableColumn> = desc
|
||||||
.columns
|
.columns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1055,7 +1062,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
cache.table_columns.insert(name.clone(), cols);
|
cache.table_columns.insert(name.clone(), cols);
|
||||||
cache.table_indexes.insert(name, index_names);
|
cache.table_indexes.insert(name, index_entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache
|
cache
|
||||||
@@ -1261,6 +1268,15 @@ fn spawn_dsl_dispatch(
|
|||||||
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
|
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
},
|
},
|
||||||
|
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
|
||||||
|
command: command.clone(),
|
||||||
|
},
|
||||||
|
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
|
||||||
|
AppEvent::DslCreateIndexSkipped {
|
||||||
|
command: command.clone(),
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
data,
|
data,
|
||||||
@@ -1662,6 +1678,15 @@ enum CommandOutcome {
|
|||||||
/// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
|
/// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
|
||||||
/// renders the "doesn't exist — skipped" note from the command.
|
/// renders the "doesn't exist — skipped" note from the command.
|
||||||
SchemaDropSkipped,
|
SchemaDropSkipped,
|
||||||
|
/// A SQL `DROP INDEX IF EXISTS` that matched no index — a no-op
|
||||||
|
/// (ADR-0035 §4d). The App renders the "doesn't exist — skipped"
|
||||||
|
/// note from the command's index name.
|
||||||
|
SchemaDropIndexSkipped,
|
||||||
|
/// A SQL `CREATE INDEX IF NOT EXISTS` that matched an existing index
|
||||||
|
/// name — a no-op (ADR-0035 §4d). Carries the resolved index name
|
||||||
|
/// (the auto-name is unknown to the command) for the "already exists
|
||||||
|
/// — skipped" note.
|
||||||
|
SchemaCreateIndexSkipped(String),
|
||||||
Query(DataResult),
|
Query(DataResult),
|
||||||
QueryPlan(QueryPlan),
|
QueryPlan(QueryPlan),
|
||||||
Insert(InsertResult),
|
Insert(InsertResult),
|
||||||
@@ -2048,6 +2073,28 @@ async fn execute_command_typed(
|
|||||||
.drop_index(selector, src)
|
.drop_index(selector, src)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||||
|
Command::SqlDropIndex { name, if_exists } => database
|
||||||
|
.sql_drop_index(name, if_exists, src)
|
||||||
|
.await
|
||||||
|
.map(|outcome| match outcome {
|
||||||
|
// Auto-show the now de-indexed table (ADR-0014), unlike
|
||||||
|
// SQL DROP TABLE whose table is gone.
|
||||||
|
DropIndexOutcome::Dropped(d) => CommandOutcome::Schema(Some(d)),
|
||||||
|
DropIndexOutcome::Skipped => CommandOutcome::SchemaDropIndexSkipped,
|
||||||
|
}),
|
||||||
|
Command::SqlCreateIndex {
|
||||||
|
name,
|
||||||
|
table,
|
||||||
|
columns,
|
||||||
|
unique,
|
||||||
|
if_not_exists,
|
||||||
|
} => database
|
||||||
|
.sql_create_index(name, table, columns, unique, if_not_exists, src)
|
||||||
|
.await
|
||||||
|
.map(|outcome| match outcome {
|
||||||
|
CreateIndexOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
|
||||||
|
CreateIndexOutcome::Skipped(n) => CommandOutcome::SchemaCreateIndexSkipped(n),
|
||||||
|
}),
|
||||||
Command::AddConstraint {
|
Command::AddConstraint {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
|
|||||||
@@ -514,8 +514,11 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
lines.push(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) {
|
if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
|
||||||
for index in indexes {
|
for index in indexes {
|
||||||
|
// Mark a UNIQUE index so the panel distinguishes it from
|
||||||
|
// a performance-only index (ADR-0035 §4d).
|
||||||
|
let unique = if index.unique { " [unique]" } else { "" };
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(" {index}"),
|
format!(" {}{unique}", index.name),
|
||||||
Style::default().fg(theme.muted),
|
Style::default().fg(theme.muted),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1280,17 +1283,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn items_panel_nests_indexes_under_their_table() {
|
fn items_panel_nests_indexes_under_their_table() {
|
||||||
// S2 (ADR-0025): the items panel renders each table
|
// S2 (ADR-0025): the items panel renders each table
|
||||||
// with its index names indented beneath it.
|
// with its index names indented beneath it. A UNIQUE index is
|
||||||
|
// marked `[unique]` (ADR-0035 §4d).
|
||||||
|
use crate::completion::IndexEntry;
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||||||
app.schema_cache.table_indexes.insert(
|
app.schema_cache.table_indexes.insert(
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
vec!["idx_email".to_string()],
|
vec![
|
||||||
|
IndexEntry { name: "idx_email".to_string(), unique: false },
|
||||||
|
IndexEntry { name: "uidx_login".to_string(), unique: true },
|
||||||
|
],
|
||||||
);
|
);
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let out = render_to_string(&mut app, &theme, 80, 24);
|
let out = render_to_string(&mut app, &theme, 80, 24);
|
||||||
assert!(out.contains("Customers"), "table listed:\n{out}");
|
assert!(out.contains("Customers"), "table listed:\n{out}");
|
||||||
assert!(out.contains("Orders"), "table listed:\n{out}");
|
assert!(out.contains("Orders"), "table listed:\n{out}");
|
||||||
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
|
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
|
||||||
|
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
//! Sub-phase 4d integration tests for advanced-mode SQL
|
||||||
|
//! `CREATE [UNIQUE] INDEX [IF NOT EXISTS]` (ADR-0035 §4d).
|
||||||
|
//!
|
||||||
|
//! `SqlCreateIndex` executes through the same `do_add_index` machinery
|
||||||
|
//! as the simple `add index`, plus the `unique` flag and the
|
||||||
|
//! `IF NOT EXISTS` no-op-with-note (`CreateIndexOutcome::Skipped`).
|
||||||
|
//! Parsing (text → `Command::SqlCreateIndex`) is covered by the
|
||||||
|
//! `sql_create_index_tests` in `src/dsl/grammar/ddl.rs`.
|
||||||
|
|
||||||
|
use rdbms_playground::db::{CreateIndexOutcome, Database};
|
||||||
|
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let project =
|
||||||
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||||
|
let persistence = Persistence::new(project.path().to_path_buf());
|
||||||
|
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
|
||||||
|
.expect("open db with persistence");
|
||||||
|
(project, db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `T (id int primary key, email text)`.
|
||||||
|
fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
|
||||||
|
r.block_on(db.sql_create_table(
|
||||||
|
"T".to_string(),
|
||||||
|
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
Some("create table T (id int primary key, email text)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create T");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str) -> bool {
|
||||||
|
r.block_on(db.insert(
|
||||||
|
"T".to_string(),
|
||||||
|
Some(vec!["id".to_string(), "email".to_string()]),
|
||||||
|
vec![Value::Number(id.to_string()), Value::Text(email.to_string())],
|
||||||
|
Some(format!("insert into T (id, email) values ({id}, '{email}')")),
|
||||||
|
))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
|
||||||
|
r.block_on(db.describe_table("T".to_string(), None))
|
||||||
|
.expect("describe")
|
||||||
|
.indexes
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| i.name == name)
|
||||||
|
.map(|i| (i.columns, i.unique))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_plain_index() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create index");
|
||||||
|
assert!(matches!(out, CreateIndexOutcome::Created(_)));
|
||||||
|
assert_eq!(index(&db, &r, "ix"), Some((vec!["email".to_string()], false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
|
||||||
|
let (p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ux".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some("create unique index ux on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create unique index");
|
||||||
|
// Reported as unique.
|
||||||
|
assert_eq!(index(&db, &r, "ux"), Some((vec!["email".to_string()], true)));
|
||||||
|
// Persisted to project.yaml as a unique index.
|
||||||
|
let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml");
|
||||||
|
assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}");
|
||||||
|
|
||||||
|
// Uniqueness is enforced by the engine.
|
||||||
|
assert!(insert_row(&db, &r, 1, "a@x"));
|
||||||
|
assert!(!insert_row(&db, &r, 2, "a@x"), "duplicate email refused by the unique index");
|
||||||
|
|
||||||
|
// Rebuild from the text artifacts: the index comes back UNIQUE
|
||||||
|
// (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain.
|
||||||
|
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), Some("rebuild".to_string())))
|
||||||
|
.expect("rebuild");
|
||||||
|
assert_eq!(
|
||||||
|
index(&db, &r, "ux"),
|
||||||
|
Some((vec!["email".to_string()], true)),
|
||||||
|
"the unique flag survived rebuild"
|
||||||
|
);
|
||||||
|
// Still enforced after rebuild.
|
||||||
|
assert!(!insert_row(&db, &r, 3, "a@x"), "uniqueness enforced after rebuild too");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_unique_index_on_duplicate_data_is_refused() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
assert!(insert_row(&db, &r, 1, "dup@x"));
|
||||||
|
assert!(insert_row(&db, &r, 2, "dup@x"));
|
||||||
|
// A unique index can't be created over columns that already hold
|
||||||
|
// duplicate values — the engine refuses at creation.
|
||||||
|
let res = r.block_on(db.sql_create_index(
|
||||||
|
Some("ux".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some("create unique index ux on T (email)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(res.is_err(), "unique index over duplicate data is refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
||||||
|
let (p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("first create");
|
||||||
|
// A second IF NOT EXISTS create of the same name is a no-op.
|
||||||
|
let line = "create index if not exists ix on T (email)";
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Some(line.to_string()),
|
||||||
|
))
|
||||||
|
.expect("IF NOT EXISTS on an existing index name succeeds as a no-op");
|
||||||
|
match out {
|
||||||
|
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
|
||||||
|
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
|
||||||
|
}
|
||||||
|
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||||
|
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unnamed_if_not_exists_skips_when_the_auto_named_index_exists() {
|
||||||
|
// The unnamed form resolves the auto-name `<T>_<cols>_idx`; the skip
|
||||||
|
// pre-check must resolve the SAME name (shared `resolve_index_name`).
|
||||||
|
// First an unnamed create (auto-named T_email_idx), then an unnamed
|
||||||
|
// IF NOT EXISTS create of the same columns → skip on the auto-name.
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
None,
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("unnamed create");
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_create_index(
|
||||||
|
None,
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Some("create index if not exists on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("unnamed IF NOT EXISTS over the auto-named index is a no-op");
|
||||||
|
match out {
|
||||||
|
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "T_email_idx"),
|
||||||
|
CreateIndexOutcome::Created(_) => panic!("expected Skipped on the auto-name, got Created"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_not_exists_short_circuits_only_a_name_collision() {
|
||||||
|
// `IF NOT EXISTS` skips only when the *name* already exists. A
|
||||||
|
// *different*-named create over already-indexed columns is not a
|
||||||
|
// name collision, so it still hits the ADR-0025 redundant-set guard
|
||||||
|
// (the playground's pedagogical refusal, not raw-SQL semantics).
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("first create");
|
||||||
|
// Same columns, a *new* name, with IF NOT EXISTS → not a name
|
||||||
|
// collision, so the redundant-set refusal still fires.
|
||||||
|
let res = r.block_on(db.sql_create_index(
|
||||||
|
Some("ix2".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Some("create index if not exists ix2 on T (email)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"IF NOT EXISTS does not bypass the redundant-column-set guard for a new name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_duplicate_name_errors() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("first create");
|
||||||
|
// Same name again, *without* IF NOT EXISTS → error.
|
||||||
|
let res = r.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["id".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix on T (id)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(res.is_err(), "duplicate index name without IF NOT EXISTS errors");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_and_unique_over_the_same_columns_are_not_duplicates() {
|
||||||
|
// The redundant-set guard keys on (columns, unique): a plain and a
|
||||||
|
// unique index over the same columns are distinct (different
|
||||||
|
// semantics). They need distinct explicit names (the auto-name would
|
||||||
|
// collide).
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix_plain".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix_plain on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("plain");
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix_unique".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some("create unique index ix_unique on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("unique over the same columns is allowed (distinct kind)");
|
||||||
|
assert_eq!(index(&db, &r, "ix_plain").map(|(_, u)| u), Some(false));
|
||||||
|
assert_eq!(index(&db, &r, "ix_unique").map(|(_, u)| u), Some(true));
|
||||||
|
|
||||||
|
// But an *exact* duplicate (same columns AND same uniqueness) is
|
||||||
|
// still refused.
|
||||||
|
let res = r.block_on(db.sql_create_index(
|
||||||
|
Some("ix_plain2".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index ix_plain2 on T (email)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(res.is_err(), "a second plain index over the same columns is redundant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
|
||||||
|
// Internal `__rdbms_*` tables are hidden from the user; indexing one
|
||||||
|
// is refused as "no such table" — via the SQL surface and the simple
|
||||||
|
// `add index` surface alike (the guard lives in the shared
|
||||||
|
// `do_add_index`, ADR-0035 §4d).
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
// SQL CREATE INDEX on an internal table → error.
|
||||||
|
let sql = r.block_on(db.sql_create_index(
|
||||||
|
Some("bad".to_string()),
|
||||||
|
"__rdbms_playground_columns".to_string(),
|
||||||
|
vec!["table_name".to_string()],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some("create index bad on __rdbms_playground_columns (table_name)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(sql.is_err(), "SQL CREATE INDEX on an internal table is refused");
|
||||||
|
// Simple `add index` on an internal table → error (same guard).
|
||||||
|
let dsl = r.block_on(db.add_index(
|
||||||
|
Some("bad2".to_string()),
|
||||||
|
"__rdbms_playground_columns".to_string(),
|
||||||
|
vec!["table_name".to_string()],
|
||||||
|
Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()),
|
||||||
|
));
|
||||||
|
assert!(dsl.is_err(), "simple add index on an internal table is refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_index_is_one_undo_step() {
|
||||||
|
let (_p, db, _d) = open(true); // undo enabled
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.sql_create_index(
|
||||||
|
Some("ix".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some("create unique index ix on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create index");
|
||||||
|
assert!(index(&db, &r, "ix").is_some());
|
||||||
|
// One undo removes the index.
|
||||||
|
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the create was one undo step");
|
||||||
|
assert!(index(&db, &r, "ix").is_none(), "undo removed the index");
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Sub-phase 4d integration tests for advanced-mode SQL
|
||||||
|
//! `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
|
||||||
|
//!
|
||||||
|
//! `SqlDropIndex` executes through the same `do_drop_index` machinery as
|
||||||
|
//! the simple `drop index <name>`; the only new behaviour is `IF EXISTS`
|
||||||
|
//! as a no-op-with-note (`DropIndexOutcome::Skipped`). These drive the
|
||||||
|
//! worker directly; parsing (text → `Command::SqlDropIndex`) is covered
|
||||||
|
//! by the `sql_drop_index_tests` in `src/dsl/grammar/ddl.rs`.
|
||||||
|
|
||||||
|
use rdbms_playground::db::{Database, DropIndexOutcome};
|
||||||
|
use rdbms_playground::dsl::{ColumnSpec, Type};
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let project =
|
||||||
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||||
|
let persistence = Persistence::new(project.path().to_path_buf());
|
||||||
|
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
|
||||||
|
.expect("open db with persistence");
|
||||||
|
(project, db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create `T (id int primary key, email text)` and an index on `email`.
|
||||||
|
fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
|
||||||
|
r.block_on(db.sql_create_table(
|
||||||
|
"T".to_string(),
|
||||||
|
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
Some("create table T (id int primary key, email text)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create T");
|
||||||
|
let desc = r
|
||||||
|
.block_on(db.add_index(
|
||||||
|
Some("T_email_idx".to_string()),
|
||||||
|
"T".to_string(),
|
||||||
|
vec!["email".to_string()],
|
||||||
|
Some("add index as T_email_idx on T (email)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("add index");
|
||||||
|
assert_eq!(desc.indexes.len(), 1, "index created");
|
||||||
|
"T_email_idx".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||||
|
r.block_on(db.describe_table("T".to_string(), None))
|
||||||
|
.expect("describe")
|
||||||
|
.indexes
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| i.name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_index_removes_an_existing_index_and_shows_the_table() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
let name = make_t_with_index(&db, &r);
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_drop_index(name, false, Some("drop index T_email_idx".to_string())))
|
||||||
|
.expect("drop index");
|
||||||
|
// Dropped carries the de-indexed table's structure (auto-show).
|
||||||
|
match out {
|
||||||
|
DropIndexOutcome::Dropped(desc) => {
|
||||||
|
assert_eq!(desc.name, "T");
|
||||||
|
assert!(desc.indexes.is_empty(), "the index is gone from the structure");
|
||||||
|
}
|
||||||
|
DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"),
|
||||||
|
}
|
||||||
|
assert!(index_names(&db, &r).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
|
||||||
|
let (p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
let line = "drop index if exists ghost_idx";
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_drop_index("ghost_idx".to_string(), true, Some(line.to_string())))
|
||||||
|
.expect("IF EXISTS on an absent index succeeds as a no-op");
|
||||||
|
assert!(matches!(out, DropIndexOutcome::Skipped));
|
||||||
|
// The no-op is still journalled (ADR-0034), like the create-skip.
|
||||||
|
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||||
|
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_drop_of_an_absent_index_errors() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
let res = r.block_on(db.sql_drop_index(
|
||||||
|
"ghost_idx".to_string(),
|
||||||
|
false,
|
||||||
|
Some("drop index ghost_idx".to_string()),
|
||||||
|
));
|
||||||
|
assert!(res.is_err(), "plain DROP INDEX on an absent index errors (no IF EXISTS)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_index_is_one_undo_step_and_restores_the_index() {
|
||||||
|
let (_p, db, _d) = open(true); // undo enabled
|
||||||
|
let r = rt();
|
||||||
|
let name = make_t_with_index(&db, &r);
|
||||||
|
r.block_on(db.sql_drop_index(name.clone(), false, Some("drop index T_email_idx".to_string())))
|
||||||
|
.expect("drop index");
|
||||||
|
assert!(index_names(&db, &r).is_empty());
|
||||||
|
|
||||||
|
// One undo brings the index back.
|
||||||
|
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||||
|
assert_eq!(index_names(&db, &r), vec![name], "undo restored the index");
|
||||||
|
}
|
||||||
@@ -227,6 +227,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
DropRelationship { .. } => "DropRelationship".into(),
|
DropRelationship { .. } => "DropRelationship".into(),
|
||||||
AddIndex { .. } => "AddIndex".into(),
|
AddIndex { .. } => "AddIndex".into(),
|
||||||
DropIndex { .. } => "DropIndex".into(),
|
DropIndex { .. } => "DropIndex".into(),
|
||||||
|
SqlDropIndex { .. } => "SqlDropIndex".into(),
|
||||||
|
SqlCreateIndex { .. } => "SqlCreateIndex".into(),
|
||||||
AddConstraint { .. } => "AddConstraint".into(),
|
AddConstraint { .. } => "AddConstraint".into(),
|
||||||
DropConstraint { .. } => "DropConstraint".into(),
|
DropConstraint { .. } => "DropConstraint".into(),
|
||||||
ShowTable { .. } => "ShowTable".into(),
|
ShowTable { .. } => "ShowTable".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user