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
+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 { .. }
));
}
}