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:
+11
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, .. }
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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", &[]),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user