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:
claude@clouddev1
2026-05-25 16:31:41 +00:00
parent 76d60591bf
commit e52e90c45b
16 changed files with 597 additions and 19 deletions
+11
View File
@@ -459,6 +459,16 @@ impl App {
self.current_table = Some(description);
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 } => {
self.handle_dsl_query_success(&command, &data);
Vec::new()
@@ -1554,6 +1564,7 @@ impl App {
(Operation::CreateTable, 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, .. } => (
Operation::AddColumn,
Some(table.as_str()),
+27 -6
View File
@@ -1458,12 +1458,14 @@ mod tests {
}
#[test]
fn drop_offers_all_five_subcommands() {
// `drop` branches: column / relationship / table / index
// (ADR-0025) / constraint (ADR-0029 §2.2). Candidates
// follow grammar declaration order, so `constraint` —
// added last — appears last.
let cs = cands("drop ", 5);
fn drop_offers_all_five_subcommands_in_simple_mode() {
// The DSL `drop` branches: column / relationship / table / index
// (ADR-0025) / constraint (ADR-0029 §2.2). Candidates follow
// grammar declaration order, so `constraint` — added last —
// appears last. Simple mode, because `drop` is a shared entry
// 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!(
cs,
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]
fn complete_command_offers_no_candidates() {
// `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());
}
}
+66
View File
@@ -477,6 +477,15 @@ enum Request {
source: Option<String>,
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 {
table: String,
column: ColumnSpec,
@@ -865,6 +874,26 @@ impl Database {
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(
&self,
table: String,
@@ -1765,6 +1794,31 @@ fn handle_request(
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 {
table,
column,
@@ -2633,6 +2687,18 @@ pub enum CreateOutcome {
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)]
fn do_create_table(
conn: &Connection,
+11
View File
@@ -160,6 +160,15 @@ pub enum Command {
DropTable {
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).
/// Its own command, but executed **structurally** through the
/// same `do_create_table` machinery as [`Self::CreateTable`] —
@@ -692,6 +701,7 @@ impl Command {
Self::CreateTable { .. } => "create table",
Self::SqlCreateTable { .. } => "create table",
Self::DropTable { .. } => "drop table",
Self::SqlDropTable { .. } => "drop table",
Self::AddColumn { .. } => "add column",
Self::DropColumn { .. } => "drop column",
Self::RenameColumn { .. } => "rename column",
@@ -741,6 +751,7 @@ impl Command {
Self::CreateTable { name, .. }
| Self::SqlCreateTable { name, .. }
| Self::DropTable { name }
| Self::SqlDropTable { name, .. }
| Self::ShowTable { name }
| Self::ShowData { name, .. } => name,
Self::AddColumn { table, .. }
+94
View File
@@ -187,6 +187,22 @@ const DROP_TABLE_NODES: &[Node] = &[
];
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>`
// =================================================================
@@ -1691,6 +1707,25 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
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)
// =================================================================
@@ -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 { .. }
));
}
}
+5
View File
@@ -590,6 +590,11 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
// the `create table … with pk …` DSL node when the SQL shape
// does not match — the `insert` precedent.
(&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
+6
View File
@@ -35,6 +35,12 @@ pub enum AppEvent {
command: Command,
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.
DslDataSucceeded { command: Command, data: DataResult },
/// An `explain …` command succeeded (ADR-0028). `plan`
+4 -1
View File
@@ -172,8 +172,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.redo", &[]),
("help.ddl.create", &[]),
("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.drop_skipped_absent", &["name"]),
("help.ddl.drop", &[]),
("help.ddl.add", &[]),
("help.ddl.rename", &[]),
@@ -245,6 +247,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]),
("parse.usage.sql_create_table", &[]),
("parse.usage.sql_drop_table", &[]),
("parse.usage.delete", &[]),
("parse.usage.drop_column", &[]),
("parse.usage.drop_constraint", &[]),
+6
View File
@@ -263,6 +263,8 @@ help:
sql_create_table: |-
create table [if not exists] <T> (<col> <type> [not null] [unique] [primary key], ...
[, primary key (<col>, ...)]) — create a table (advanced SQL)
sql_drop_table: |-
drop table [if exists] <T> — remove a table (advanced SQL)
drop: |-
drop table <T> — remove a table
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
# "already exists" error.
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:
# Wrapper around chumsky's structural error message. The
@@ -446,6 +451,7 @@ parse:
usage:
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_drop_table: "drop table [if exists] <Name>"
drop_table: "drop table <Name>"
drop_column: "drop column [from] [table] <Table>: <Name>"
drop_relationship: |-
+16 -1
View File
@@ -30,7 +30,8 @@ use crate::app::App;
use crate::cli::Args;
use crate::db::{
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::walker::Severity;
@@ -1257,6 +1258,9 @@ fn spawn_dsl_dispatch(
command: command.clone(),
description,
},
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
command: command.clone(),
},
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
command: command.clone(),
data,
@@ -1654,6 +1658,10 @@ enum CommandOutcome {
/// so the App can render it alongside the "already exists —
/// skipped" note.
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),
QueryPlan(QueryPlan),
Insert(InsertResult),
@@ -1948,6 +1956,13 @@ async fn execute_command_typed(
.drop_table(name, src)
.await
.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 {
table,
column,