feat: ADR-0035 4c — DROP TABLE [IF EXISTS]
Add advanced-mode SQL `DROP TABLE [IF EXISTS] <name>` -> SqlDropTable, executing through the existing do_drop_table (cascade / inbound- relationship refusal / metadata cleanup) — full parity with the simple `drop table`. The only new behaviour is `IF EXISTS` as a no-op-with-note: a new DropOutcome::Skipped mirroring CreateOutcome::Skipped (journalled, no snapshot), rendered via a new ddl.drop_skipped_absent note + DslDropSkipped event. - Grammar: SQL_DROP_TABLE node (entry `drop`, shape `table [if exists] <name> [;]`), registered Advanced. SQL-first dispatch: `drop table T` -> SqlDropTable in advanced; `drop column`/`relationship`/`index`/ `constraint` fall back to the simple `drop` node (and still execute). - Worker: Request::SqlDropTable + db.sql_drop_table; the if-exists-and- absent arm journals + replies Skipped without a snapshot, else snapshot_then(do_drop_table) -> Dropped. - Completion: advanced `drop ` now surfaces the SQL `table` (the shared-entry-word behaviour from `create`); test split into simple (full DSL list) + advanced (SQL surface). Known shared-entry-word completion unevenness (advanced `drop ` offers only `table`; partial `drop rel` returns an empty list) deferred to 4i (merge candidate sets for shared entry words) along with a flagged user request to visually distinguish simple- vs advanced-mode completions in the hint UI — tracked in ADR §13 4i (d)/(e), the 4c plan, and the completion test. The DSL drops still parse + execute via fallback. 10 new tests (parse/builder + Tier-3: drop existing + one-undo-step + restore, IF EXISTS skip + journal, plain-absent error, inbound refusal). Docs: ADR-0035 Status/§13, README, requirements.md Q1. Tests: 1805 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
@@ -3,13 +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**
|
**validated end-to-end by sub-phases 4a / 4a.2 / 4a.3 / 4b / 4c**
|
||||||
(`CREATE TABLE` with column- and table-level constraints and foreign
|
(`CREATE TABLE` with column- and table-level constraints and foreign
|
||||||
keys, implemented 2026-05-25 — plans
|
keys, and `DROP TABLE [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`), so the decision is
|
`docs/plans/20260525-adr-0035-sql-ddl-4b.md`, `…-4c.md`), so the
|
||||||
accepted while the remaining sub-phases (**4c–4i**, §13) continue. This
|
decision is accepted while the remaining sub-phases (**4d–4i**, §13)
|
||||||
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**
|
||||||
on how DDL is represented and executed.
|
on how DDL is represented and executed.
|
||||||
@@ -367,8 +367,19 @@ ADR-0033's structure:
|
|||||||
takes `CONSTRAINT <name>`. PK-target only (UNIQUE-target deferred with
|
takes `CONSTRAINT <name>`. PK-target only (UNIQUE-target deferred with
|
||||||
`add relationship`); `Type::fk_target_type` (ADR-0011) governs type
|
`add relationship`); `Type::fk_target_type` (ADR-0011) governs type
|
||||||
compatibility.
|
compatibility.
|
||||||
- **4c — `DROP TABLE [IF EXISTS]`** → `SqlDropTable` (cascade parity;
|
- **4c — `DROP TABLE [IF EXISTS]`** → `SqlDropTable`. *(Implemented
|
||||||
`IF EXISTS` no-op-with-note, §4).
|
2026-05-25 — plan `docs/plans/20260525-adr-0035-sql-ddl-4c.md`.)*
|
||||||
|
Reuses `do_drop_table` (cascade parity + the inbound-relationship
|
||||||
|
refusal + metadata cleanup), so it matches the simple `drop table`;
|
||||||
|
`IF EXISTS` on an absent table is a no-op-with-note (a new
|
||||||
|
`DropOutcome::Skipped` mirroring `CreateOutcome::Skipped`; journalled,
|
||||||
|
no snapshot, §4). `drop` is a shared entry word: `drop table` parses
|
||||||
|
as `SqlDropTable` in advanced mode, `drop column`/`relationship`/
|
||||||
|
`index`/`constraint` fall back to the simple `drop` node. Advanced-
|
||||||
|
mode `drop ` completion now surfaces the SQL `table` (the
|
||||||
|
shared-entry-word behaviour from `create`, ADR-0033 Amendment 3); the
|
||||||
|
DSL drops still parse via fallback — 4i grows the surface as `DROP
|
||||||
|
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` (ADR-0025; the `UNIQUE` flag extension if needed).
|
||||||
- **4e — `ALTER TABLE` add/drop/rename column.** Drop/rename column
|
- **4e — `ALTER TABLE` add/drop/rename column.** Drop/rename column
|
||||||
@@ -394,7 +405,21 @@ ADR-0033's structure:
|
|||||||
schema-existence diagnostic falsely flags the not-yet-created self
|
schema-existence diagnostic falsely flags the not-yet-created self
|
||||||
table as unknown (the FK parent slot is `IdentSource::Tables`). Make
|
table as unknown (the FK parent slot is `IdentSource::Tables`). Make
|
||||||
the diagnostic treat a FK parent equal to the `CREATE TABLE` target as
|
the diagnostic treat a FK parent equal to the `CREATE TABLE` target as
|
||||||
valid, so the indicator stops lying for self-references.
|
valid, so the indicator stops lying for self-references. (d) **4c
|
||||||
|
shared-entry-word completion merge** — in advanced mode a shared entry
|
||||||
|
word surfaces only the SQL node's continuations, so `drop ` offers
|
||||||
|
only `table` (not the DSL `column`/`relationship`/`index`/`constraint`)
|
||||||
|
and a partial keyword like `drop rel` returns an *empty* list (a
|
||||||
|
mid-word dead end), even though the DSL drops still parse + execute via
|
||||||
|
fallback. Merge the expected sets of all candidate nodes for a shared
|
||||||
|
entry word so advanced completion offers every valid continuation
|
||||||
|
(`drop ` → table + column + relationship + index + constraint; `drop
|
||||||
|
rel` → relationship); verify `create`/`insert`/`update`/`delete`
|
||||||
|
completion stays sensible. (e) **Discussion flag (user, 2026-05-25):**
|
||||||
|
before/with (d), discuss **visually distinguishing simple- vs
|
||||||
|
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
|
||||||
|
design conversation, not just the mechanical merge.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,158 @@
|
|||||||
|
# Plan: ADR-0035 Phase 4, sub-phase 4c — `DROP TABLE [IF EXISTS]`
|
||||||
|
|
||||||
|
Add advanced-mode SQL `DROP TABLE [IF EXISTS] <name>` → `SqlDropTable`.
|
||||||
|
Reuses the existing `do_drop_table` (cascade/inbound-refusal +
|
||||||
|
metadata cleanup), so it has full parity with the simple `drop table`;
|
||||||
|
the only new behaviour is `IF EXISTS` as a **no-op-that-succeeds-with-a-
|
||||||
|
note**, mirroring 4a's `CreateOutcome::Skipped` with a `DropOutcome`
|
||||||
|
(ADR-0035 §4/§13). Small slice.
|
||||||
|
|
||||||
|
## 1. Baseline
|
||||||
|
|
||||||
|
- Tests: **1795 passing, 0 failing, 1 ignored**; clippy clean. Branch
|
||||||
|
`main`, last commit `76d6059` (4b). 4c starts here.
|
||||||
|
|
||||||
|
## 2. Decisions (from ADR §4/§13 + the 4a precedent — not re-litigated)
|
||||||
|
|
||||||
|
1. **`IF EXISTS` → no-op-with-note** (universal cross-vendor idiom,
|
||||||
|
already in scope per the 4a `IF [NOT] EXISTS` decision): dropping an
|
||||||
|
absent table succeeds with a note instead of the plain "no such
|
||||||
|
table" error. Mirrors `CreateOutcome::Skipped` via a new
|
||||||
|
`DropOutcome { Dropped, Skipped }`. The skip is **journalled** (no
|
||||||
|
snapshot), exactly as the create-skip is.
|
||||||
|
2. **Table only.** SQL `DROP TABLE` drops a table; `DROP INDEX` is 4c's
|
||||||
|
sibling 4d (`SqlDropIndex`); column/constraint drops are `ALTER TABLE`
|
||||||
|
(4e+). `drop column`/`drop relationship`/`drop index` keep falling
|
||||||
|
back to the simple `drop` node.
|
||||||
|
3. **Dispatch (ADR-0033 Amendment 1).** `drop` is a shared entry word;
|
||||||
|
the SQL node is tried first in advanced mode. `drop table T` parses
|
||||||
|
as `SqlDropTable` in advanced mode (equivalent execution to the
|
||||||
|
simple `DropTable` — both call `do_drop_table`); the simple form is
|
||||||
|
the only one in simple mode.
|
||||||
|
4. **Cascade / inbound-relationship refusal parity** — reuse
|
||||||
|
`do_drop_table` unchanged (it already refuses dropping a table with
|
||||||
|
inbound relationships and cleans `__rdbms_*` metadata, incl. the 4a.3
|
||||||
|
`table_checks` rows). One undo step (`snapshot_then`).
|
||||||
|
|
||||||
|
## 3. Phase 1 — Requirements checklist (4c)
|
||||||
|
|
||||||
|
- [ ] `DROP TABLE <name>` parses in advanced mode → `SqlDropTable`.
|
||||||
|
- [ ] `DROP TABLE IF EXISTS <name>` sets the `if_exists` flag.
|
||||||
|
- [ ] Dropping an existing table removes it + its metadata (parity with
|
||||||
|
simple `drop table`); one undo step; `undo` restores it.
|
||||||
|
- [ ] `IF EXISTS` on an **absent** table → success with a note, no
|
||||||
|
error, no snapshot; the line is journalled.
|
||||||
|
- [ ] Plain `DROP TABLE` on an absent table → the existing "no such
|
||||||
|
table" error (unchanged).
|
||||||
|
- [ ] Dropping a table with **inbound relationships** is refused
|
||||||
|
(existing `do_drop_table` semantics), in advanced mode too.
|
||||||
|
- [ ] `drop column` / `drop relationship` / `drop index` still parse as
|
||||||
|
the simple forms in advanced mode (fallback).
|
||||||
|
- [ ] Engine-neutral note wording.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [ ] **Tier 1** (builder): `drop table <name>` → `SqlDropTable{if_exists:
|
||||||
|
false}`; `drop table if exists <name>` → `if_exists: true`; the simple
|
||||||
|
`drop column`/`drop relationship` forms still parse (fallback).
|
||||||
|
- [ ] **Tier 3** (`tests/sql_drop_table.rs`): drop existing → gone +
|
||||||
|
one undo step + undo restores; `IF EXISTS` absent → skipped + note +
|
||||||
|
journalled; plain absent → error; inbound-relationship refusal.
|
||||||
|
- [ ] **Catalog** lockstep + vocab audit for the new note key.
|
||||||
|
|
||||||
|
## 4. Architecture & design
|
||||||
|
|
||||||
|
### 4.1 Grammar (`src/dsl/grammar/ddl.rs`)
|
||||||
|
- `IF_EXISTS_OPT` = `Optional(Seq[Word("if"), Word("exists")])`.
|
||||||
|
- `SQL_DROP_TABLE_SHAPE` = `Seq[Word("table"), IF_EXISTS_OPT,
|
||||||
|
TABLE_NAME_EXISTING, Optional(Punct(';'))]` (mirrors the simple
|
||||||
|
`DROP_TABLE` shape + the optional `IF EXISTS` + trailing `;`).
|
||||||
|
- `pub static SQL_DROP_TABLE: CommandNode { entry: "drop", shape,
|
||||||
|
ast_builder: build_sql_drop_table, help_id: "ddl.sql_drop_table",
|
||||||
|
usage_ids: ["parse.usage.sql_drop_table"] }`.
|
||||||
|
- `build_sql_drop_table`: `name = require_ident("table_name")`,
|
||||||
|
`if_exists = path.contains_word("if")` (the `if` only appears in the
|
||||||
|
`IF EXISTS` prefix; mirror the create builder's `if_not_exists`).
|
||||||
|
|
||||||
|
### 4.2 Command (`src/dsl/command.rs`)
|
||||||
|
`SqlDropTable { name: String, if_exists: bool }`. Verb label
|
||||||
|
`"drop table"`; `target_table()` → `name` (add to the existing match
|
||||||
|
arms alongside `DropTable`).
|
||||||
|
|
||||||
|
### 4.3 Worker (`src/db.rs`)
|
||||||
|
- `DropOutcome { Dropped, Skipped }` (the drop peer of `CreateOutcome`;
|
||||||
|
`Skipped` needs no payload — the runtime renders the note from the
|
||||||
|
command's name).
|
||||||
|
- `Request::SqlDropTable { name, if_exists, source, reply:
|
||||||
|
oneshot<Result<DropOutcome>> }` + `db.sql_drop_table` method.
|
||||||
|
- Dispatch arm: if `if_exists && !user_table_exists(name)` → journal the
|
||||||
|
line (no snapshot) and reply `Skipped` (mirror the create-skip arm);
|
||||||
|
else `snapshot_then(… do_drop_table(name) … → Dropped)`.
|
||||||
|
- `do_drop_table` unchanged.
|
||||||
|
|
||||||
|
### 4.4 Runtime + event + app (`src/runtime.rs`, `src/event.rs`, `src/app.rs`)
|
||||||
|
- `Command::SqlDropTable` → `db.sql_drop_table` → map `Dropped` →
|
||||||
|
`CommandOutcome::Schema(None)` (same as simple drop), `Skipped` → a new
|
||||||
|
`CommandOutcome::SchemaDropSkipped`.
|
||||||
|
- `CommandOutcome::SchemaDropSkipped` → `AppEvent::DslDropSkipped {
|
||||||
|
command }`; the app handler notes `ddl.drop_skipped_absent` with
|
||||||
|
`command.target_table()` (mirror `DslCreateSkipped`). No structure to
|
||||||
|
render (the table is gone / never existed).
|
||||||
|
|
||||||
|
### 4.5 Friendly catalog
|
||||||
|
- New key `ddl.drop_skipped_absent: "table '{name}' doesn't exist —
|
||||||
|
skipped (no changes made)"` + a `keys.rs` `("ddl.drop_skipped_absent",
|
||||||
|
&["name"])` entry. Engine-neutral. Help/usage skeleton body for
|
||||||
|
`ddl.sql_drop_table` + `parse.usage.sql_drop_table` (a fresh node — its
|
||||||
|
keys must exist; unlike the deferred *refresh* of the create skeleton,
|
||||||
|
these are new keys the node references, so they land now).
|
||||||
|
|
||||||
|
## 5. Out of 4c scope
|
||||||
|
- `DROP INDEX` (4d → `SqlDropIndex`); `ALTER TABLE` (4e–4h).
|
||||||
|
- **Shared-entry-word completion (deferred to 4i, user-confirmed
|
||||||
|
2026-05-25).** Adding the SQL `drop` node makes advanced-mode `drop `
|
||||||
|
completion surface only `table`; a partial DSL subcommand keyword
|
||||||
|
(`drop rel`) returns an empty list (mid-word dead end). The DSL drops
|
||||||
|
still parse + execute via fallback — only the completion hint is
|
||||||
|
affected. This is the pre-existing shared-entry-word model (same as
|
||||||
|
`create`/`insert`/`update`/`delete`), exposed here because `drop` has
|
||||||
|
five distinct DSL subcommands with no SQL equivalent. 4i merges the
|
||||||
|
candidate sets for shared entry words; **the user also wants to
|
||||||
|
discuss visually distinguishing simple- vs advanced-mode completions
|
||||||
|
in the hint UI (likely by colour)** before/with that work. Tracked in
|
||||||
|
ADR-0035 §13 4i (d)/(e) and the advanced-mode completion test in
|
||||||
|
`src/completion.rs`.
|
||||||
|
|
||||||
|
## 6. Devil's Advocate review of this plan
|
||||||
|
- **Reuse vs fork?** `do_drop_table` is the single executor; the SQL
|
||||||
|
path differs only at the `IF EXISTS` branch (mirrors the create-skip).
|
||||||
|
✓
|
||||||
|
- **Dispatch fallback?** A builder test asserts `drop column`/`drop
|
||||||
|
relationship` still parse simple in advanced mode. ✓
|
||||||
|
- **One undo step + restore?** `snapshot_then` wrap + a dedicated undo
|
||||||
|
test (drop → undo → table back, with its data/metadata). ✓
|
||||||
|
- **No-op journalled, not snapshotted?** Mirrors the create-skip arm
|
||||||
|
exactly; a journalling test. ✓
|
||||||
|
- **Engine-neutral?** The note is a catalog key under the vocab audit;
|
||||||
|
the plain-absent error is the existing `do_drop_table` path (unchanged,
|
||||||
|
already engine-neutral). ✓
|
||||||
|
|
||||||
|
## 7. Implementation sequence (test-first)
|
||||||
|
1. **Command + grammar + builder** — Tier-1 builder tests (parse +
|
||||||
|
`if_exists` + simple fallback) → red → add `SqlDropTable`, the grammar
|
||||||
|
node + shape + builder, REGISTRY entry, thread `Request`/method/
|
||||||
|
dispatch/runtime (drop execution can land here too) → green.
|
||||||
|
2. **Worker no-op-with-note** — Tier-3 (`tests/sql_drop_table.rs`): drop
|
||||||
|
existing, `IF EXISTS` skip + journal, plain-absent error, inbound
|
||||||
|
refusal, one undo step + restore → red → add `DropOutcome` + the
|
||||||
|
dispatch arm + the `SchemaDropSkipped`/`DslDropSkipped`/app-note path
|
||||||
|
→ green.
|
||||||
|
3. **Catalog** — add the note + help/usage keys + bodies; lockstep +
|
||||||
|
vocab audit → green.
|
||||||
|
4. **Full sweep** — `cargo test` (no regression from 1795) + clippy.
|
||||||
|
5. **Docs** — ADR-0035 Status/§13 4c; README; `requirements.md` Q1.
|
||||||
|
Propose commit; wait for approval.
|
||||||
|
|
||||||
|
## 8. Exit gate
|
||||||
|
- All §3 items satisfied; four tiers green, zero skips; no regression
|
||||||
|
from the 1795 baseline; written DA pass; clippy clean.
|
||||||
@@ -223,8 +223,9 @@ handoff-14 cleanup; 449 after B2/C2.)
|
|||||||
table, since the engine reports no CHECK constraints), then foreign
|
table, since the engine reports no CHECK constraints), then foreign
|
||||||
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)). Remaining DDL — `DROP TABLE`
|
bare `REFERENCES <parent>` supported), then `DROP TABLE [IF EXISTS]`
|
||||||
(4c), indexes (4d), `ALTER TABLE` (4e–4h) — is phased per
|
(4c — reuses `do_drop_table`; `IF EXISTS` is a no-op-with-note)).
|
||||||
|
Remaining DDL — indexes (4d), `ALTER TABLE` (4e–4h) — is phased per
|
||||||
ADR-0035 §13.)*
|
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.
|
||||||
|
|||||||
+11
@@ -459,6 +459,16 @@ impl App {
|
|||||||
self.current_table = Some(description);
|
self.current_table = Some(description);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslDropSkipped { command } => {
|
||||||
|
// No-op (DROP TABLE IF EXISTS on an absent table,
|
||||||
|
// ADR-0035 §4, 4c): just the skip note — no structure,
|
||||||
|
// no misleading "[ok] drop table" line.
|
||||||
|
self.note_system(crate::t!(
|
||||||
|
"ddl.drop_skipped_absent",
|
||||||
|
name = command.target_table()
|
||||||
|
));
|
||||||
|
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()
|
||||||
@@ -1554,6 +1564,7 @@ impl App {
|
|||||||
(Operation::CreateTable, Some(name.as_str()), None)
|
(Operation::CreateTable, Some(name.as_str()), None)
|
||||||
}
|
}
|
||||||
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
|
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
|
||||||
|
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
|
||||||
C::AddColumn { table, column, .. } => (
|
C::AddColumn { table, column, .. } => (
|
||||||
Operation::AddColumn,
|
Operation::AddColumn,
|
||||||
Some(table.as_str()),
|
Some(table.as_str()),
|
||||||
|
|||||||
+27
-6
@@ -1458,12 +1458,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_offers_all_five_subcommands() {
|
fn drop_offers_all_five_subcommands_in_simple_mode() {
|
||||||
// `drop` branches: column / relationship / table / index
|
// The DSL `drop` branches: column / relationship / table / index
|
||||||
// (ADR-0025) / constraint (ADR-0029 §2.2). Candidates
|
// (ADR-0025) / constraint (ADR-0029 §2.2). Candidates follow
|
||||||
// follow grammar declaration order, so `constraint` —
|
// grammar declaration order, so `constraint` — added last —
|
||||||
// added last — appears last.
|
// appears last. Simple mode, because `drop` is a shared entry
|
||||||
let cs = cands("drop ", 5);
|
// word: advanced mode surfaces the SQL `DROP TABLE` completion
|
||||||
|
// instead (ADR-0033 Amendment 3 / ADR-0035 §4c — see below).
|
||||||
|
let cs = cands_simple("drop ", 5);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cs,
|
cs,
|
||||||
vec![
|
vec![
|
||||||
@@ -1476,6 +1478,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_in_advanced_mode_surfaces_the_sql_drop_table_completion() {
|
||||||
|
// ADR-0035 §4c: `drop` gained an advanced SQL node
|
||||||
|
// (`DROP TABLE [IF EXISTS]`). As with the `create`/`insert`/
|
||||||
|
// `update`/`delete` shared entry words (ADR-0033 Amendment 3),
|
||||||
|
// advanced mode surfaces the SQL grammar's completion — here
|
||||||
|
// just `table` — rather than the DSL subcommands. The DSL drops
|
||||||
|
// (`drop column` etc.) still parse via fallback; only the
|
||||||
|
// completion hint differs (and a partial DSL keyword like
|
||||||
|
// `drop rel` returns an empty list — a mid-word dead end).
|
||||||
|
// ADR-0035 §13 4i (d)/(e) tracks merging the candidate sets for
|
||||||
|
// shared entry words, and the user's request to visually
|
||||||
|
// distinguish simple- vs advanced-mode completions in the hint
|
||||||
|
// UI (likely by colour); this expectation grows when 4i lands.
|
||||||
|
assert_eq!(cands("drop ", 5), vec!["table".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn complete_command_offers_no_candidates() {
|
fn complete_command_offers_no_candidates() {
|
||||||
// `create table T with pk` is a complete command —
|
// `create table T with pk` is a complete command —
|
||||||
@@ -2444,3 +2463,5 @@ mod tests {
|
|||||||
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,15 @@ enum Request {
|
|||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<(), DbError>>,
|
reply: oneshot::Sender<Result<(), DbError>>,
|
||||||
},
|
},
|
||||||
|
/// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
|
||||||
|
/// Executes through `do_drop_table`; `if_exists` turns an absent
|
||||||
|
/// table into a no-op (`DropOutcome::Skipped`, no snapshot).
|
||||||
|
SqlDropTable {
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
|
||||||
|
},
|
||||||
AddColumn {
|
AddColumn {
|
||||||
table: String,
|
table: String,
|
||||||
column: ColumnSpec,
|
column: ColumnSpec,
|
||||||
@@ -865,6 +874,26 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
|
||||||
|
/// Returns whether the table was dropped or skipped (the `IF EXISTS`
|
||||||
|
/// no-op on an absent table).
|
||||||
|
pub async fn sql_drop_table(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
source: Option<String>,
|
||||||
|
) -> Result<DropOutcome, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::SqlDropTable {
|
||||||
|
name,
|
||||||
|
if_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,
|
||||||
@@ -1765,6 +1794,31 @@ fn handle_request(
|
|||||||
do_drop_table(conn, persistence, source.as_deref(), &name)
|
do_drop_table(conn, persistence, source.as_deref(), &name)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Request::SqlDropTable {
|
||||||
|
name,
|
||||||
|
if_exists,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
} => {
|
||||||
|
// `IF EXISTS` on an absent table is a no-op: reply `Skipped`
|
||||||
|
// and take **no** snapshot (nothing to undo). The submitted
|
||||||
|
// line is still journalled — like the `CREATE TABLE IF NOT
|
||||||
|
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
|
||||||
|
if if_exists && !user_table_exists(conn, &name).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(DropOutcome::Skipped)
|
||||||
|
})();
|
||||||
|
let _ = reply.send(result);
|
||||||
|
} else {
|
||||||
|
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||||
|
do_drop_table(conn, persistence, source.as_deref(), &name)
|
||||||
|
.map(|()| DropOutcome::Dropped)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Request::AddColumn {
|
Request::AddColumn {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
@@ -2633,6 +2687,18 @@ pub enum CreateOutcome {
|
|||||||
Skipped(TableDescription),
|
Skipped(TableDescription),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The result of an advanced-mode SQL `DROP TABLE` (ADR-0035 §4, 4c).
|
||||||
|
///
|
||||||
|
/// Either the table was dropped, or `IF EXISTS` matched no table and
|
||||||
|
/// the statement was a no-op that drives the "doesn't exist — skipped"
|
||||||
|
/// note. Carries no payload — the runtime renders the note from the
|
||||||
|
/// command's table name.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DropOutcome {
|
||||||
|
Dropped,
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn do_create_table(
|
fn do_create_table(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ pub enum Command {
|
|||||||
DropTable {
|
DropTable {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
/// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name>` (ADR-0035 §4,
|
||||||
|
/// sub-phase 4c). Executes through the same `do_drop_table`
|
||||||
|
/// machinery as [`Self::DropTable`] (cascade / inbound-relationship
|
||||||
|
/// refusal / metadata cleanup); `if_exists` turns an absent table
|
||||||
|
/// into a no-op-with-note rather than an error.
|
||||||
|
SqlDropTable {
|
||||||
|
name: String,
|
||||||
|
if_exists: bool,
|
||||||
|
},
|
||||||
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, sub-phase 4a).
|
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, sub-phase 4a).
|
||||||
/// Its own command, but executed **structurally** through the
|
/// Its own command, but executed **structurally** through the
|
||||||
/// same `do_create_table` machinery as [`Self::CreateTable`] —
|
/// same `do_create_table` machinery as [`Self::CreateTable`] —
|
||||||
@@ -692,6 +701,7 @@ impl Command {
|
|||||||
Self::CreateTable { .. } => "create table",
|
Self::CreateTable { .. } => "create table",
|
||||||
Self::SqlCreateTable { .. } => "create table",
|
Self::SqlCreateTable { .. } => "create table",
|
||||||
Self::DropTable { .. } => "drop table",
|
Self::DropTable { .. } => "drop table",
|
||||||
|
Self::SqlDropTable { .. } => "drop table",
|
||||||
Self::AddColumn { .. } => "add column",
|
Self::AddColumn { .. } => "add column",
|
||||||
Self::DropColumn { .. } => "drop column",
|
Self::DropColumn { .. } => "drop column",
|
||||||
Self::RenameColumn { .. } => "rename column",
|
Self::RenameColumn { .. } => "rename column",
|
||||||
@@ -741,6 +751,7 @@ impl Command {
|
|||||||
Self::CreateTable { name, .. }
|
Self::CreateTable { name, .. }
|
||||||
| Self::SqlCreateTable { name, .. }
|
| Self::SqlCreateTable { name, .. }
|
||||||
| Self::DropTable { name }
|
| Self::DropTable { name }
|
||||||
|
| Self::SqlDropTable { name, .. }
|
||||||
| Self::ShowTable { name }
|
| Self::ShowTable { name }
|
||||||
| Self::ShowData { name, .. } => name,
|
| Self::ShowData { name, .. } => name,
|
||||||
Self::AddColumn { table, .. }
|
Self::AddColumn { table, .. }
|
||||||
|
|||||||
@@ -187,6 +187,22 @@ const DROP_TABLE_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||||||
|
|
||||||
|
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||||||
|
// sub-phase 4c). Same table-only target as the simple `drop table`,
|
||||||
|
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
|
||||||
|
// `table` keyword (not the Optional) keeps the element/dispatch
|
||||||
|
// matching honest.
|
||||||
|
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
|
||||||
|
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
|
||||||
|
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
|
||||||
|
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("table")),
|
||||||
|
SQL_DROP_IF_EXISTS_OPT,
|
||||||
|
TABLE_NAME_EXISTING,
|
||||||
|
Node::Optional(&Node::Punct(';')),
|
||||||
|
];
|
||||||
|
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// drop_column — `drop column [from] [table] <T> : <col>`
|
// drop_column — `drop column [from] [table] <T> : <col>`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1691,6 +1707,25 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
|
|||||||
usage_ids: &["parse.usage.sql_create_table"],
|
usage_ids: &["parse.usage.sql_create_table"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Build a `Command::SqlDropTable` from the advanced-mode SQL
|
||||||
|
/// `DROP TABLE [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4c).
|
||||||
|
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
|
||||||
|
/// flag (mirroring `build_sql_create_table`'s `if_not_exists`).
|
||||||
|
fn build_sql_drop_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
|
Ok(Command::SqlDropTable {
|
||||||
|
name: require_ident(path, "table_name")?,
|
||||||
|
if_exists: path.contains_word("if"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("drop"),
|
||||||
|
shape: SQL_DROP_TABLE_SHAPE,
|
||||||
|
ast_builder: build_sql_drop_table,
|
||||||
|
help_id: Some("ddl.sql_drop_table"),
|
||||||
|
usage_ids: &["parse.usage.sql_drop_table"],
|
||||||
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1900,3 +1935,62 @@ mod constraint_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Tests — advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sql_drop_table_tests {
|
||||||
|
use crate::dsl::command::Command;
|
||||||
|
use crate::dsl::parser::parse_command_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
fn drop_fields(input: &str) -> (String, bool) {
|
||||||
|
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||||
|
Command::SqlDropTable { name, if_exists } => (name, if_exists),
|
||||||
|
other => panic!("expected SqlDropTable, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_table_parses_as_sql_drop_table_in_advanced_mode() {
|
||||||
|
let (name, if_exists) = drop_fields("drop table Orders");
|
||||||
|
assert_eq!(name, "Orders");
|
||||||
|
assert!(!if_exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_exists_sets_the_flag() {
|
||||||
|
let (name, if_exists) = drop_fields("drop table if exists Orders");
|
||||||
|
assert_eq!(name, "Orders");
|
||||||
|
assert!(if_exists);
|
||||||
|
// trailing semicolon tolerated
|
||||||
|
assert!(drop_fields("drop table if exists Orders;").1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_drop_table_in_simple_mode_is_the_dsl_command() {
|
||||||
|
// In simple mode the SQL node is gated; `drop table T` is the
|
||||||
|
// simple `DropTable` (which has no `if_exists`).
|
||||||
|
match parse_command_in_mode("drop table Orders", Mode::Simple).expect("parses") {
|
||||||
|
Command::DropTable { name } => assert_eq!(name, "Orders"),
|
||||||
|
other => panic!("expected DropTable, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_drops_fall_back_to_the_simple_node_in_advanced_mode() {
|
||||||
|
// `drop column` / `drop relationship` are not SQL DROP TABLE —
|
||||||
|
// they fall through to the simple `drop` node even in advanced.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("drop column from Orders: note", Mode::Advanced).expect("parses"),
|
||||||
|
Command::DropColumn { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
|
||||||
|
.expect("parses"),
|
||||||
|
Command::DropRelationship { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -590,6 +590,11 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
|||||||
// the `create table … with pk …` DSL node when the SQL shape
|
// the `create table … with pk …` DSL node when the SQL shape
|
||||||
// does not match — the `insert` precedent.
|
// does not match — the `insert` precedent.
|
||||||
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
||||||
|
// Shared `drop` entry word: `ddl::DROP` (simple) and this advanced
|
||||||
|
// SQL node. SQL-first in advanced mode; `drop table [if exists] T`
|
||||||
|
// matches here while `drop column`/`drop relationship`/`drop index`
|
||||||
|
// fall back to the simple `drop` node.
|
||||||
|
(&ddl::SQL_DROP_TABLE, CommandCategory::Advanced),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ pub enum AppEvent {
|
|||||||
command: Command,
|
command: Command,
|
||||||
description: TableDescription,
|
description: TableDescription,
|
||||||
},
|
},
|
||||||
|
/// A SQL `DROP TABLE IF EXISTS` matched no table — a no-op
|
||||||
|
/// (ADR-0035 §4, 4c). Renders a "doesn't exist — skipped" note;
|
||||||
|
/// there is no structure to show.
|
||||||
|
DslDropSkipped {
|
||||||
|
command: Command,
|
||||||
|
},
|
||||||
/// 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`
|
||||||
|
|||||||
@@ -172,8 +172,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.app.redo", &[]),
|
("help.app.redo", &[]),
|
||||||
("help.ddl.create", &[]),
|
("help.ddl.create", &[]),
|
||||||
("help.ddl.sql_create_table", &[]),
|
("help.ddl.sql_create_table", &[]),
|
||||||
// Advanced-mode SQL CREATE TABLE no-op note (ADR-0035 §4).
|
("help.ddl.sql_drop_table", &[]),
|
||||||
|
// 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"]),
|
||||||
("help.ddl.drop", &[]),
|
("help.ddl.drop", &[]),
|
||||||
("help.ddl.add", &[]),
|
("help.ddl.add", &[]),
|
||||||
("help.ddl.rename", &[]),
|
("help.ddl.rename", &[]),
|
||||||
@@ -245,6 +247,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.change_column", &[]),
|
("parse.usage.change_column", &[]),
|
||||||
("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.delete", &[]),
|
("parse.usage.delete", &[]),
|
||||||
("parse.usage.drop_column", &[]),
|
("parse.usage.drop_column", &[]),
|
||||||
("parse.usage.drop_constraint", &[]),
|
("parse.usage.drop_constraint", &[]),
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ help:
|
|||||||
sql_create_table: |-
|
sql_create_table: |-
|
||||||
create table [if not exists] <T> (<col> <type> [not null] [unique] [primary key], ...
|
create table [if not exists] <T> (<col> <type> [not null] [unique] [primary key], ...
|
||||||
[, primary key (<col>, ...)]) — create a table (advanced SQL)
|
[, primary key (<col>, ...)]) — create a table (advanced SQL)
|
||||||
|
sql_drop_table: |-
|
||||||
|
drop table [if exists] <T> — remove a table (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
|
||||||
@@ -377,6 +379,9 @@ ddl:
|
|||||||
# present: a no-op that succeeds with this note instead of an
|
# present: a no-op that succeeds with this note instead of an
|
||||||
# "already exists" error.
|
# "already exists" error.
|
||||||
create_skipped_exists: "table '{name}' already exists — skipped (no changes made)"
|
create_skipped_exists: "table '{name}' already exists — skipped (no changes made)"
|
||||||
|
# `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.
|
||||||
|
drop_skipped_absent: "table '{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
|
||||||
@@ -446,6 +451,7 @@ parse:
|
|||||||
usage:
|
usage:
|
||||||
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>"
|
||||||
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: |-
|
||||||
|
|||||||
+16
-1
@@ -30,7 +30,8 @@ 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, CreateOutcome, DataResult, Database, DbError,
|
||||||
DeleteResult, DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
|
DeleteResult, DropColumnResult, DropOutcome, InsertResult, QueryPlan, TableDescription,
|
||||||
|
UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::{Command, ColumnSpec};
|
use crate::dsl::{Command, ColumnSpec};
|
||||||
use crate::dsl::walker::Severity;
|
use crate::dsl::walker::Severity;
|
||||||
@@ -1257,6 +1258,9 @@ fn spawn_dsl_dispatch(
|
|||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
|
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
|
||||||
|
command: command.clone(),
|
||||||
|
},
|
||||||
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
data,
|
data,
|
||||||
@@ -1654,6 +1658,10 @@ enum CommandOutcome {
|
|||||||
/// so the App can render it alongside the "already exists —
|
/// so the App can render it alongside the "already exists —
|
||||||
/// skipped" note.
|
/// skipped" note.
|
||||||
SchemaSkipped(TableDescription),
|
SchemaSkipped(TableDescription),
|
||||||
|
/// A SQL `DROP TABLE IF EXISTS` that matched no table — a no-op
|
||||||
|
/// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
|
||||||
|
/// renders the "doesn't exist — skipped" note from the command.
|
||||||
|
SchemaDropSkipped,
|
||||||
Query(DataResult),
|
Query(DataResult),
|
||||||
QueryPlan(QueryPlan),
|
QueryPlan(QueryPlan),
|
||||||
Insert(InsertResult),
|
Insert(InsertResult),
|
||||||
@@ -1948,6 +1956,13 @@ async fn execute_command_typed(
|
|||||||
.drop_table(name, src)
|
.drop_table(name, src)
|
||||||
.await
|
.await
|
||||||
.map(|()| CommandOutcome::Schema(None)),
|
.map(|()| CommandOutcome::Schema(None)),
|
||||||
|
Command::SqlDropTable { name, if_exists } => database
|
||||||
|
.sql_drop_table(name, if_exists, src)
|
||||||
|
.await
|
||||||
|
.map(|outcome| match outcome {
|
||||||
|
DropOutcome::Dropped => CommandOutcome::Schema(None),
|
||||||
|
DropOutcome::Skipped => CommandOutcome::SchemaDropSkipped,
|
||||||
|
}),
|
||||||
Command::AddColumn {
|
Command::AddColumn {
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Sub-phase 4c integration tests for advanced-mode SQL
|
||||||
|
//! `DROP TABLE [IF EXISTS]` (ADR-0035 §4).
|
||||||
|
//!
|
||||||
|
//! `SqlDropTable` executes through the same `do_drop_table` machinery
|
||||||
|
//! as the simple `drop table` (cascade / inbound-relationship refusal /
|
||||||
|
//! metadata cleanup); the only new behaviour is `IF EXISTS` as a
|
||||||
|
//! no-op-with-note (`DropOutcome::Skipped`). These drive the worker
|
||||||
|
//! directly; parsing (text → `Command::SqlDropTable`) is covered by the
|
||||||
|
//! `sql_drop_table_tests` in `src/dsl/grammar/ddl.rs`.
|
||||||
|
|
||||||
|
use rdbms_playground::db::{Database, DropOutcome};
|
||||||
|
use rdbms_playground::dsl::{ColumnSpec, SqlForeignKey, 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 a simple `T (id int primary key, body 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("body", Type::Text)],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
Some("create table T (id int primary key, body text)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_table_removes_an_existing_table() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
|
||||||
|
.expect("drop");
|
||||||
|
assert!(matches!(out, DropOutcome::Dropped));
|
||||||
|
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
|
||||||
|
let (p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
let line = "drop table if exists Ghost";
|
||||||
|
let out = r
|
||||||
|
.block_on(db.sql_drop_table("Ghost".to_string(), true, Some(line.to_string())))
|
||||||
|
.expect("IF EXISTS on an absent table succeeds as a no-op");
|
||||||
|
assert!(matches!(out, DropOutcome::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_table_errors() {
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
let res = r.block_on(db.sql_drop_table("Ghost".to_string(), false, Some("drop table Ghost".to_string())));
|
||||||
|
assert!(res.is_err(), "plain DROP TABLE on an absent table errors (no IF EXISTS)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_referenced_parent_is_refused() {
|
||||||
|
// Parity with `do_drop_table`: a table with inbound relationships
|
||||||
|
// can't be dropped (ADR-0013), via the SQL path too.
|
||||||
|
let (_p, db, _d) = open(false);
|
||||||
|
let r = rt();
|
||||||
|
r.block_on(db.sql_create_table(
|
||||||
|
"parent".to_string(),
|
||||||
|
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
Some("create table parent (id serial primary key, label text)".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create parent");
|
||||||
|
r.block_on(db.sql_create_table(
|
||||||
|
"child".to_string(),
|
||||||
|
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![SqlForeignKey {
|
||||||
|
name: None,
|
||||||
|
child_column: "pid".to_string(),
|
||||||
|
parent_table: "parent".to_string(),
|
||||||
|
parent_column: Some("id".to_string()),
|
||||||
|
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||||
|
on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||||
|
}],
|
||||||
|
false,
|
||||||
|
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
|
||||||
|
))
|
||||||
|
.expect("create child with FK");
|
||||||
|
|
||||||
|
// The parent is referenced — refused (even with IF EXISTS, since the
|
||||||
|
// table *does* exist; the refusal is about the relationship).
|
||||||
|
assert!(
|
||||||
|
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
|
||||||
|
.is_err(),
|
||||||
|
"a referenced parent can't be dropped"
|
||||||
|
);
|
||||||
|
// Dropping the child first succeeds, then the parent.
|
||||||
|
r.block_on(db.sql_drop_table("child".to_string(), false, Some("drop table child".to_string())))
|
||||||
|
.expect("drop child");
|
||||||
|
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string())))
|
||||||
|
.expect("now the parent drops");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_table_is_one_undo_step_and_restores_data() {
|
||||||
|
let (_p, db, _d) = open(true); // undo enabled
|
||||||
|
let r = rt();
|
||||||
|
make_t(&db, &r);
|
||||||
|
r.block_on(db.insert(
|
||||||
|
"T".to_string(),
|
||||||
|
Some(vec!["id".to_string(), "body".to_string()]),
|
||||||
|
vec![Value::Number("1".to_string()), Value::Text("hi".to_string())],
|
||||||
|
Some("insert".to_string()),
|
||||||
|
))
|
||||||
|
.expect("row");
|
||||||
|
r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
|
||||||
|
.expect("drop");
|
||||||
|
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||||
|
|
||||||
|
// One undo brings the table — and its row — back.
|
||||||
|
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||||
|
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||||
|
let data = r
|
||||||
|
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||||
|
.expect("query");
|
||||||
|
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
CreateTable { .. } => "CreateTable".into(),
|
CreateTable { .. } => "CreateTable".into(),
|
||||||
SqlCreateTable { .. } => "SqlCreateTable".into(),
|
SqlCreateTable { .. } => "SqlCreateTable".into(),
|
||||||
DropTable { .. } => "DropTable".into(),
|
DropTable { .. } => "DropTable".into(),
|
||||||
|
SqlDropTable { .. } => "SqlDropTable".into(),
|
||||||
AddColumn { .. } => "AddColumn".into(),
|
AddColumn { .. } => "AddColumn".into(),
|
||||||
DropColumn { .. } => "DropColumn".into(),
|
DropColumn { .. } => "DropColumn".into(),
|
||||||
RenameColumn { .. } => "RenameColumn".into(),
|
RenameColumn { .. } => "RenameColumn".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user