feat: ADR-0035 4d — CREATE [UNIQUE] INDEX / DROP INDEX
Advanced-mode SQL CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols) -> SqlCreateIndex and DROP INDEX [IF EXISTS] <name> -> SqlDropIndex, both reusing the ADR-0025 executors (do_add_index / do_drop_index), like 4c reused do_drop_table. - CREATE UNIQUE INDEX admitted in advanced mode (ADR-0025 Amendment 1): ADR-0025 deferred UNIQUE indexes for the simple-mode DSL, but advanced mode trusts the user like SQL does. Adds an additive IndexSchema.unique flag (project.yaml, serde-default, version stays 1); rebuild re-emits CREATE UNIQUE INDEX; the redundant-set guard keys on (columns, unique). Simple-mode `add unique index` stays deferred. - IF [NOT] EXISTS on both forms reuses the 4c no-op-with-note skip (journalled, not snapshotted) via CreateIndexOutcome / DropIndexOutcome. - Unnamed CREATE INDEX auto-named (ADR-0025 convention); the [UNIQUE] prefix is a concrete-keyword Choice and the optional name an on-led-first selector (the drop-index selector precedent) — trap-safe. - create/drop each gain a second advanced node; the existing all-candidates dispatch handles it (locked by parse tests). - Unique indexes marked [unique] in the structure view and items panel. - do_add_index refuses internal __rdbms_* tables as "no such table", closing a latent exposure on both the simple `add index` and the new SQL CREATE INDEX surfaces (ADR-0025 Amendment 1). Docs: ADR-0035 status + §13 4d + 4i; ADR-0025 Amendment 1; ADR README; requirements.md Q1/C3. Plan: docs/plans/20260525-adr-0035-sql-ddl-4d.md. Tests: 1834 passing / 0 failing / 0 skipped / 1 ignored; clippy clean.
This commit is contained in:
+27
@@ -469,6 +469,24 @@ impl App {
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslDropIndexSkipped { command } => {
|
||||
// No-op (DROP INDEX IF EXISTS on an absent index,
|
||||
// ADR-0035 §4d): just the skip note. `target_table()`
|
||||
// returns the index name for `SqlDropIndex`.
|
||||
self.note_system(crate::t!(
|
||||
"ddl.drop_index_skipped_absent",
|
||||
name = command.target_table()
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslCreateIndexSkipped { command: _, name } => {
|
||||
// No-op (CREATE INDEX IF NOT EXISTS on an existing index
|
||||
// name, ADR-0035 §4d): the skip note carries the resolved
|
||||
// index name (the unnamed form's auto-name isn't on the
|
||||
// command). No structure shown.
|
||||
self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslDataSucceeded { command, data } => {
|
||||
self.handle_dsl_query_success(&command, &data);
|
||||
Vec::new()
|
||||
@@ -1603,6 +1621,12 @@ impl App {
|
||||
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
|
||||
},
|
||||
C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
|
||||
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
|
||||
// (it reuses `do_add_index`); route engine/validation errors
|
||||
// through it with the parsed table.
|
||||
C::SqlCreateIndex { table, .. } => {
|
||||
(Operation::AddIndex, Some(table.as_str()), None)
|
||||
}
|
||||
C::AddConstraint { table, column, .. } => (
|
||||
Operation::AddConstraint,
|
||||
Some(table.as_str()),
|
||||
@@ -1619,6 +1643,9 @@ impl App {
|
||||
}
|
||||
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
|
||||
},
|
||||
// The SQL `DROP INDEX` is name-only (the table is resolved by
|
||||
// the executor), like the named DSL drop.
|
||||
C::SqlDropIndex { .. } => (Operation::DropIndex, None, None),
|
||||
C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
|
||||
C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None),
|
||||
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
|
||||
|
||||
+13
-3
@@ -48,9 +48,19 @@ pub struct SchemaCache {
|
||||
/// case-insensitive in `columns_for_table` so the walker
|
||||
/// can resolve `Customers` regardless of how it was typed.
|
||||
pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
|
||||
/// Per-table user index names (ADR-0025). Keyed by table
|
||||
/// name; drives the nested tables/indexes items panel (S2).
|
||||
pub table_indexes: std::collections::HashMap<String, Vec<String>>,
|
||||
/// Per-table user indexes (ADR-0025). Keyed by table name; drives
|
||||
/// the nested tables/indexes items panel (S2). Each entry carries
|
||||
/// the index's uniqueness so the panel can mark a UNIQUE index
|
||||
/// (ADR-0035 §4d).
|
||||
pub table_indexes: std::collections::HashMap<String, Vec<IndexEntry>>,
|
||||
}
|
||||
|
||||
/// One per-table index for the items panel (ADR-0025 / ADR-0035 §4d):
|
||||
/// its name and whether it is a UNIQUE index.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IndexEntry {
|
||||
pub name: String,
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// One column's user-facing type info, scoped to a table
|
||||
|
||||
@@ -486,6 +486,28 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
|
||||
},
|
||||
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4d).
|
||||
/// Executes through `do_drop_index`; `if_exists` turns an absent
|
||||
/// index into a no-op (`DropIndexOutcome::Skipped`, no snapshot).
|
||||
SqlDropIndex {
|
||||
name: String,
|
||||
if_exists: bool,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<DropIndexOutcome, DbError>>,
|
||||
},
|
||||
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
|
||||
/// (ADR-0035 §4d). Executes through `do_add_index` (with `unique`);
|
||||
/// `if_not_exists` turns an existing index name into a no-op
|
||||
/// (`CreateIndexOutcome::Skipped`, no snapshot).
|
||||
SqlCreateIndex {
|
||||
name: Option<String>,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
unique: bool,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<CreateIndexOutcome, DbError>>,
|
||||
},
|
||||
AddColumn {
|
||||
table: String,
|
||||
column: ColumnSpec,
|
||||
@@ -894,6 +916,53 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Advanced-mode SQL `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
|
||||
/// Returns whether the index was dropped (with the affected table's
|
||||
/// structure) or skipped (the `IF EXISTS` no-op on an absent index).
|
||||
pub async fn sql_drop_index(
|
||||
&self,
|
||||
name: String,
|
||||
if_exists: bool,
|
||||
source: Option<String>,
|
||||
) -> Result<DropIndexOutcome, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::SqlDropIndex {
|
||||
name,
|
||||
if_exists,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
|
||||
/// (ADR-0035 §4d). Returns whether the index was created (with the
|
||||
/// affected table's structure) or skipped (the `IF NOT EXISTS` no-op
|
||||
/// on an existing index name, carrying the resolved name).
|
||||
pub async fn sql_create_index(
|
||||
&self,
|
||||
name: Option<String>,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
unique: bool,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
) -> Result<CreateIndexOutcome, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::SqlCreateIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn add_column(
|
||||
&self,
|
||||
table: String,
|
||||
@@ -1950,6 +2019,10 @@ fn handle_request(
|
||||
name.as_deref(),
|
||||
&table,
|
||||
&columns,
|
||||
// Simple-mode `add index` is always non-unique
|
||||
// (ADR-0025); `add unique index` stays deferred. The SQL
|
||||
// `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d).
|
||||
false,
|
||||
));
|
||||
}
|
||||
Request::DropIndex {
|
||||
@@ -1964,6 +2037,76 @@ fn handle_request(
|
||||
&selector,
|
||||
));
|
||||
}
|
||||
Request::SqlDropIndex {
|
||||
name,
|
||||
if_exists,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
// `IF EXISTS` on an absent index is a no-op: reply `Skipped`
|
||||
// and take **no** snapshot (nothing to undo). The submitted
|
||||
// line is still journalled (the 4c skip pattern, ADR-0034 /
|
||||
// ADR-0035 §4). Existence uses the same user-index lookup as
|
||||
// `do_drop_index` (`sql IS NOT NULL`).
|
||||
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
|
||||
let result = (|| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(DropIndexOutcome::Skipped)
|
||||
})();
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_drop_index(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&IndexSelector::Named { name: name.clone() },
|
||||
)
|
||||
.map(DropIndexOutcome::Dropped)
|
||||
});
|
||||
}
|
||||
}
|
||||
Request::SqlCreateIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
// `IF NOT EXISTS` short-circuits only a *name* collision into
|
||||
// a no-op (reply `Skipped`, no snapshot, line journalled — the
|
||||
// 4c skip pattern). The name uses the broad lookup (any index
|
||||
// of that name), matching `do_add_index`'s collision guard.
|
||||
// A *different*-named but redundant-column-set create still
|
||||
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
|
||||
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
|
||||
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
|
||||
let result = (|| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
|
||||
})();
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_add_index(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
name.as_deref(),
|
||||
&table,
|
||||
&columns,
|
||||
unique,
|
||||
)
|
||||
.map(CreateIndexOutcome::Created)
|
||||
});
|
||||
}
|
||||
}
|
||||
Request::AddConstraint {
|
||||
table,
|
||||
column,
|
||||
@@ -2415,6 +2558,7 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
||||
name: idx.name,
|
||||
table: name.clone(),
|
||||
columns: idx.columns,
|
||||
unique: idx.unique,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2699,6 +2843,34 @@ pub enum DropOutcome {
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// The result of an advanced-mode SQL `DROP INDEX` (ADR-0035 §4d).
|
||||
///
|
||||
/// Either the index was dropped — `Dropped` carries the affected
|
||||
/// table's structure so the runtime auto-shows the now de-indexed table
|
||||
/// (ADR-0014), unlike `DROP TABLE` whose table is gone — or `IF EXISTS`
|
||||
/// matched no index and the statement was a no-op driving the "doesn't
|
||||
/// exist — skipped" note (the index name comes from the command).
|
||||
#[derive(Debug)]
|
||||
pub enum DropIndexOutcome {
|
||||
Dropped(TableDescription),
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// The result of an advanced-mode SQL `CREATE [UNIQUE] INDEX`
|
||||
/// (ADR-0035 §4d).
|
||||
///
|
||||
/// Either the index was created — `Created` carries the affected table's
|
||||
/// structure for the auto-show (ADR-0014) — or `IF NOT EXISTS` matched
|
||||
/// an existing index name and the statement was a no-op. `Skipped`
|
||||
/// carries the **resolved** index name (the auto-name is unknown to the
|
||||
/// command for the unnamed form) to drive the "already exists — skipped"
|
||||
/// note.
|
||||
#[derive(Debug)]
|
||||
pub enum CreateIndexOutcome {
|
||||
Created(TableDescription),
|
||||
Skipped(String),
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn do_create_table(
|
||||
conn: &Connection,
|
||||
@@ -5859,6 +6031,38 @@ fn do_drop_relationship(
|
||||
/// Refuses a redundant index on an already-indexed column set
|
||||
/// and a name collision. The index name is auto-generated as
|
||||
/// `<table>_<col…>_idx` when not supplied.
|
||||
/// Resolve an index name: the user-given name, or the ADR-0025
|
||||
/// auto-name `<table>_<col…>_idx`. Shared by `do_add_index` and the
|
||||
/// `CREATE INDEX IF NOT EXISTS` skip pre-check (ADR-0035 §4d) so both
|
||||
/// compute the same name.
|
||||
fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> String {
|
||||
name.map_or_else(
|
||||
|| format!("{table}_{}_idx", columns.join("_")),
|
||||
ToString::to_string,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
|
||||
///
|
||||
/// `user_only = true` counts only explicit `CREATE INDEX` objects
|
||||
/// (`sql IS NOT NULL`), matching `do_drop_index`'s named lookup — used
|
||||
/// by the `DROP INDEX IF EXISTS` skip. `user_only = false` counts any
|
||||
/// index of that name (incl. the automatic PK / UNIQUE-constraint
|
||||
/// indexes), matching `do_add_index`'s name-collision guard — used by
|
||||
/// the `CREATE INDEX IF NOT EXISTS` skip.
|
||||
fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result<bool, DbError> {
|
||||
let sql = if user_only {
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;"
|
||||
} else {
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1;"
|
||||
};
|
||||
let count: i64 = conn
|
||||
.query_row(sql, [name], |row| row.get(0))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
fn do_add_index(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
@@ -5866,7 +6070,21 @@ fn do_add_index(
|
||||
name: Option<&str>,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
unique: bool,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
// 0. Internal `__rdbms_*` tables are not user tables (they are
|
||||
// filtered from `list_tables` and never offered in completion), so
|
||||
// indexing one is refused as "no such table" — the same opacity
|
||||
// the rest of the app presents. Guards BOTH the simple `add index`
|
||||
// and the SQL `CREATE INDEX` surfaces, since both reach here
|
||||
// (ADR-0025 / ADR-0035 §4d; the grammar's `reject_internal_table`
|
||||
// only covers the typed SQL family, not the simple node).
|
||||
if table.to_ascii_lowercase().starts_with("__rdbms_") {
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such table: {table}"),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
});
|
||||
}
|
||||
// 1. Table must exist; gather its columns.
|
||||
let schema = read_schema(conn, table)?;
|
||||
// 2. Every indexed column must exist on the table.
|
||||
@@ -5878,11 +6096,17 @@ fn do_add_index(
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. Refuse a redundant index over an identical column set.
|
||||
// 3. Refuse a redundant index over an identical column set *of the
|
||||
// same kind*. A plain and a unique index over the same columns are
|
||||
// NOT redundant (the unique one enforces a constraint the plain one
|
||||
// does not), so the guard keys on `(columns, unique)` (ADR-0035
|
||||
// §4d). To hold both, the user must name them distinctly — the
|
||||
// auto-name is identical, so the name guard (step 5) would
|
||||
// otherwise collide.
|
||||
let existing = read_table_indexes(conn, table)?;
|
||||
if let Some(dup) = existing
|
||||
.iter()
|
||||
.find(|i| i.columns.as_slice() == columns)
|
||||
.find(|i| i.columns.as_slice() == columns && i.unique == unique)
|
||||
{
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"the columns ({}) of `{table}` are already indexed by `{}`.",
|
||||
@@ -5891,10 +6115,7 @@ fn do_add_index(
|
||||
)));
|
||||
}
|
||||
// 4. Resolve the index name (auto-generate when omitted).
|
||||
let resolved = name.map_or_else(
|
||||
|| format!("{table}_{}_idx", columns.join("_")),
|
||||
ToString::to_string,
|
||||
);
|
||||
let resolved = resolve_index_name(name, table, columns);
|
||||
// 5. Refuse a name collision.
|
||||
let name_taken: i64 = conn
|
||||
.query_row(
|
||||
@@ -5919,13 +6140,14 @@ fn do_add_index(
|
||||
.map(|c| quote_ident(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let unique_kw = if unique { "UNIQUE " } else { "" };
|
||||
let ddl = format!(
|
||||
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
||||
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
|
||||
idx = quote_ident(&resolved),
|
||||
tbl = quote_ident(table),
|
||||
cols = cols_csv,
|
||||
);
|
||||
debug!(ddl = %ddl, "add_index");
|
||||
debug!(ddl = %ddl, unique, "add_index");
|
||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||
let description = do_describe_table(conn, table)?;
|
||||
let changes = Changes {
|
||||
@@ -7818,8 +8040,12 @@ fn do_rebuild_from_text(
|
||||
.map(|c| quote_ident(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
// ADR-0035 §4d: a UNIQUE index round-trips its uniqueness, so
|
||||
// re-emit `CREATE UNIQUE INDEX` — otherwise a rebuild would
|
||||
// silently demote it to a plain index.
|
||||
let unique_kw = if index.unique { "UNIQUE " } else { "" };
|
||||
tx.execute_batch(&format!(
|
||||
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
||||
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
|
||||
idx = quote_ident(&index.name),
|
||||
tbl = quote_ident(&index.table),
|
||||
))
|
||||
|
||||
@@ -278,6 +278,29 @@ pub enum Command {
|
||||
DropIndex {
|
||||
selector: IndexSelector,
|
||||
},
|
||||
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4,
|
||||
/// sub-phase 4d). Name-only (SQL has no positional column form — that
|
||||
/// is the simple `drop index on T(…)`). Executes through the same
|
||||
/// `do_drop_index` machinery as [`Self::DropIndex`]; `if_exists`
|
||||
/// turns an absent index into a no-op-with-note rather than an error.
|
||||
SqlDropIndex {
|
||||
name: String,
|
||||
if_exists: bool,
|
||||
},
|
||||
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>]
|
||||
/// ON <table> (<col>, …)` (ADR-0035 §4d). Executes through the same
|
||||
/// `do_add_index` machinery as [`Self::AddIndex`] (the columns/
|
||||
/// auto-name reuse), plus the `unique` flag (simple mode has no
|
||||
/// `add unique index` — that stays deferred per ADR-0025). `name` is
|
||||
/// `None` for the unnamed form (auto-named at execution);
|
||||
/// `if_not_exists` makes an existing index name a no-op-with-note.
|
||||
SqlCreateIndex {
|
||||
name: Option<String>,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
unique: bool,
|
||||
if_not_exists: bool,
|
||||
},
|
||||
/// Add a column-level constraint to an existing column
|
||||
/// (ADR-0029 §2.2). Applied through the rebuild-table
|
||||
/// primitive after a §5 dry-run guards populated columns.
|
||||
@@ -710,6 +733,8 @@ impl Command {
|
||||
Self::DropRelationship { .. } => "drop relationship",
|
||||
Self::AddIndex { .. } => "add index",
|
||||
Self::DropIndex { .. } => "drop index",
|
||||
Self::SqlDropIndex { .. } => "drop index",
|
||||
Self::SqlCreateIndex { .. } => "create index",
|
||||
Self::AddConstraint { .. } => "add constraint",
|
||||
Self::DropConstraint { .. } => "drop constraint",
|
||||
Self::ShowTable { .. } => "show table",
|
||||
@@ -783,6 +808,11 @@ impl Command {
|
||||
// sensible fallback for logging.
|
||||
IndexSelector::Named { name } => name,
|
||||
},
|
||||
// The SQL drop is name-only; the index name identifies it
|
||||
// until the executor resolves the table (mirrors the named
|
||||
// `DropIndex` / `SqlDropTable` fallback).
|
||||
Self::SqlDropIndex { name, .. } => name,
|
||||
Self::SqlCreateIndex { table, .. } => table,
|
||||
// Replay isn't tied to a single table; the path is
|
||||
// the most identifying thing for log output.
|
||||
Self::Replay { path } => path,
|
||||
|
||||
@@ -203,6 +203,21 @@ static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
||||
];
|
||||
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);
|
||||
|
||||
// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||||
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
|
||||
// form (that stays the simple `drop index on …`, which falls back to
|
||||
// the simple `drop` node). Leads on the concrete `index` keyword; the
|
||||
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
|
||||
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
|
||||
// still parses and reaches the skip path.
|
||||
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("index")),
|
||||
SQL_DROP_IF_EXISTS_OPT,
|
||||
INDEX_NAME_EXISTING,
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);
|
||||
|
||||
// =================================================================
|
||||
// drop_column — `drop column [from] [table] <T> : <col>`
|
||||
// =================================================================
|
||||
@@ -1726,6 +1741,106 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||||
usage_ids: &["parse.usage.sql_drop_table"],
|
||||
};
|
||||
|
||||
/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
|
||||
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
|
||||
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
|
||||
/// flag (mirroring `build_sql_drop_table`).
|
||||
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::SqlDropIndex {
|
||||
name: require_ident(path, "index_name")?,
|
||||
if_exists: path.contains_word("if"),
|
||||
})
|
||||
}
|
||||
|
||||
pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||
entry: Word::keyword("drop"),
|
||||
shape: SQL_DROP_INDEX_SHAPE,
|
||||
ast_builder: build_sql_drop_index,
|
||||
help_id: Some("ddl.sql_drop_index"),
|
||||
usage_ids: &["parse.usage.sql_drop_index"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
|
||||
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
|
||||
// node (alongside SQL_CREATE_TABLE).
|
||||
// =================================================================
|
||||
|
||||
// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
|
||||
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
||||
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
||||
// builder reads `unique` presence via `contains_word("unique")`.
|
||||
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
|
||||
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
||||
static SQL_CI_LEAD_CHOICES: &[Node] =
|
||||
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||||
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
||||
|
||||
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("if")),
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Word(Word::keyword("exists")),
|
||||
];
|
||||
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));
|
||||
|
||||
// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
|
||||
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
|
||||
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
|
||||
// instead greedily consume the `on` keyword (`consume_ident` does not
|
||||
// reject keywords), breaking the unnamed form.
|
||||
static SQL_CI_UNNAMED_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("on")),
|
||||
TABLE_NAME_EXISTING,
|
||||
Node::Punct('('),
|
||||
INDEX_COLUMN_LIST,
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
|
||||
static SQL_CI_NAMED_NODES: &[Node] = &[
|
||||
INDEX_NAME_NEW,
|
||||
Node::Word(Word::keyword("on")),
|
||||
TABLE_NAME_EXISTING,
|
||||
Node::Punct('('),
|
||||
INDEX_COLUMN_LIST,
|
||||
Node::Punct(')'),
|
||||
];
|
||||
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
|
||||
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
|
||||
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);
|
||||
|
||||
static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
|
||||
SQL_CI_LEAD,
|
||||
SQL_CI_IF_NOT_EXISTS_OPT,
|
||||
SQL_CI_SELECTOR,
|
||||
Node::Optional(&Node::Punct(';')),
|
||||
];
|
||||
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);
|
||||
|
||||
/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
|
||||
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
|
||||
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
|
||||
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
|
||||
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
|
||||
/// extraction mirrors the simple `add index` builder.
|
||||
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::SqlCreateIndex {
|
||||
name: ident(path, "index_name").map(str::to_string),
|
||||
table: require_ident(path, "table_name")?,
|
||||
columns: collect_idents(path, "column_name"),
|
||||
unique: path.contains_word("unique"),
|
||||
if_not_exists: path.contains_word("if"),
|
||||
})
|
||||
}
|
||||
|
||||
pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
entry: Word::keyword("create"),
|
||||
shape: SQL_CREATE_INDEX_SHAPE,
|
||||
ast_builder: build_sql_create_index,
|
||||
help_id: Some("ddl.sql_create_index"),
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
|
||||
// =================================================================
|
||||
@@ -1994,3 +2109,187 @@ mod sql_drop_table_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sql_drop_index_tests {
|
||||
use crate::dsl::command::{Command, IndexSelector};
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
fn drop_index_fields(input: &str) -> (String, bool) {
|
||||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||
Command::SqlDropIndex { name, if_exists } => (name, if_exists),
|
||||
other => panic!("expected SqlDropIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
|
||||
let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
|
||||
assert_eq!(name, "Orders_CustId_idx");
|
||||
assert!(!if_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_exists_sets_the_flag() {
|
||||
let (name, if_exists) = drop_index_fields("drop index if exists ix");
|
||||
assert_eq!(name, "ix");
|
||||
assert!(if_exists);
|
||||
// trailing semicolon tolerated
|
||||
assert!(drop_index_fields("drop index if exists ix;").1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
|
||||
// `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
|
||||
// SQL_DROP_INDEX); the dispatcher must try both and pick the
|
||||
// shape that matches (ADR-0035 §4d — the second-advanced-node
|
||||
// case).
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
|
||||
Command::SqlDropTable { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
|
||||
Command::SqlDropIndex { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
|
||||
// The SQL form is name-only; `drop index on T (cols)` is the
|
||||
// simple positional form. The name-only SQL shape can't fully
|
||||
// match it (trailing `(cols)`), so it falls back to the simple
|
||||
// `drop` node's `DropIndex { Columns }` even in advanced mode.
|
||||
match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
|
||||
.expect("parses")
|
||||
{
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Columns { table, columns },
|
||||
} => {
|
||||
assert_eq!(table, "Orders");
|
||||
assert_eq!(columns, vec!["CustId".to_string()]);
|
||||
}
|
||||
other => panic!("expected positional DropIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_drop_index_in_simple_mode_is_the_dsl_command() {
|
||||
// In simple mode the SQL node is gated; `drop index ix` is the
|
||||
// simple `DropIndex { Named }`.
|
||||
match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Named { name },
|
||||
} => assert_eq!(name, "ix"),
|
||||
other => panic!("expected named DropIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sql_create_index_tests {
|
||||
use crate::dsl::command::Command;
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
struct Ci {
|
||||
name: Option<String>,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
unique: bool,
|
||||
if_not_exists: bool,
|
||||
}
|
||||
|
||||
fn ci(input: &str) -> Ci {
|
||||
match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
|
||||
Command::SqlCreateIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
} => Ci { name, table, columns, unique, if_not_exists },
|
||||
other => panic!("expected SqlCreateIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_create_index_parses() {
|
||||
let c = ci("create index ix on Customers (email)");
|
||||
assert_eq!(c.name.as_deref(), Some("ix"));
|
||||
assert_eq!(c.table, "Customers");
|
||||
assert_eq!(c.columns, vec!["email".to_string()]);
|
||||
assert!(!c.unique);
|
||||
assert!(!c.if_not_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_create_index_leaves_name_none() {
|
||||
// The unnamed form: the optional name must NOT swallow `on`
|
||||
// (the `DI_SELECTOR`-style on-led-first selector handles it).
|
||||
let c = ci("create index on Customers (email)");
|
||||
assert_eq!(c.name, None);
|
||||
assert_eq!(c.table, "Customers");
|
||||
assert_eq!(c.columns, vec!["email".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_sets_the_flag() {
|
||||
let c = ci("create unique index ux on Customers (email)");
|
||||
assert!(c.unique);
|
||||
assert_eq!(c.name.as_deref(), Some("ux"));
|
||||
// unnamed unique form too
|
||||
let c2 = ci("create unique index on Customers (email)");
|
||||
assert!(c2.unique);
|
||||
assert_eq!(c2.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_not_exists_sets_the_flag() {
|
||||
let c = ci("create index if not exists ix on Customers (email)");
|
||||
assert!(c.if_not_exists);
|
||||
assert_eq!(c.name.as_deref(), Some("ix"));
|
||||
// combined with unique + unnamed + trailing semicolon
|
||||
let c2 = ci("create unique index if not exists on Customers (email);");
|
||||
assert!(c2.unique && c2.if_not_exists);
|
||||
assert_eq!(c2.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_column_index_parses() {
|
||||
let c = ci("create index on Orders (CustId, Date)");
|
||||
assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
|
||||
// `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
|
||||
// SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
|
||||
.expect("parses"),
|
||||
Command::SqlCreateTable { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
|
||||
Command::SqlCreateIndex { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
|
||||
.expect("parses"),
|
||||
Command::SqlCreateIndex { unique: true, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_create_table_dsl_still_parses_in_advanced_mode() {
|
||||
// The `create table … with pk …` DSL form falls back to the
|
||||
// simple node even with two advanced `create` nodes present.
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
|
||||
.expect("parses"),
|
||||
Command::CreateTable { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-8
@@ -585,16 +585,24 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||
(&data::SQL_DELETE, CommandCategory::Advanced),
|
||||
// Shared entry word `create` (ADR-0035 §2): the simple
|
||||
// `ddl::CREATE` (above) and this advanced SQL node. The
|
||||
// dispatcher tries SQL first in advanced mode and falls back to
|
||||
// the `create table … with pk …` DSL node when the SQL shape
|
||||
// does not match — the `insert` precedent.
|
||||
// `ddl::CREATE` (above) and these advanced SQL nodes. The
|
||||
// dispatcher tries the advanced candidates first in advanced mode
|
||||
// and falls back to the `create table … with pk …` DSL node when no
|
||||
// SQL shape matches — the `insert` precedent. 4d adds
|
||||
// SQL_CREATE_INDEX, so `create` now has *two* advanced nodes;
|
||||
// `decide` tries both (`create table …` → SQL_CREATE_TABLE,
|
||||
// `create [unique] index …` → SQL_CREATE_INDEX).
|
||||
(&ddl::SQL_CREATE_TABLE, CommandCategory::Advanced),
|
||||
// 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_CREATE_INDEX, CommandCategory::Advanced),
|
||||
// Shared `drop` entry word: `ddl::DROP` (simple) and these advanced
|
||||
// SQL nodes. SQL-first in advanced mode; `drop table [if exists] T`
|
||||
// → SQL_DROP_TABLE, `drop index [if exists] <name>` → SQL_DROP_INDEX
|
||||
// (4d — `drop` now has *two* advanced nodes; the dispatcher's
|
||||
// `decide` tries all advanced candidates). `drop column`/`drop
|
||||
// relationship`/`drop index on T(…)` fall back to the simple `drop`
|
||||
// node.
|
||||
(&ddl::SQL_DROP_TABLE, CommandCategory::Advanced),
|
||||
(&ddl::SQL_DROP_INDEX, CommandCategory::Advanced),
|
||||
];
|
||||
|
||||
/// Whether `entry` names an advanced-mode-only command (ADR-0030
|
||||
|
||||
@@ -41,6 +41,20 @@ pub enum AppEvent {
|
||||
DslDropSkipped {
|
||||
command: Command,
|
||||
},
|
||||
/// A SQL `DROP INDEX IF EXISTS` matched no index — a no-op
|
||||
/// (ADR-0035 §4d). Renders an index-specific "doesn't exist —
|
||||
/// skipped" note; no structure to show.
|
||||
DslDropIndexSkipped {
|
||||
command: Command,
|
||||
},
|
||||
/// A SQL `CREATE INDEX IF NOT EXISTS` matched an existing index name
|
||||
/// — a no-op (ADR-0035 §4d). `name` is the resolved index name (the
|
||||
/// auto-name is not on the command). Renders "already exists —
|
||||
/// skipped"; no structure to show.
|
||||
DslCreateIndexSkipped {
|
||||
command: Command,
|
||||
name: String,
|
||||
},
|
||||
/// A `show data` query succeeded.
|
||||
DslDataSucceeded { command: Command, data: DataResult },
|
||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||
|
||||
@@ -173,9 +173,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.ddl.create", &[]),
|
||||
("help.ddl.sql_create_table", &[]),
|
||||
("help.ddl.sql_drop_table", &[]),
|
||||
("help.ddl.sql_create_index", &[]),
|
||||
("help.ddl.sql_drop_index", &[]),
|
||||
// Advanced-mode SQL CREATE TABLE / DROP TABLE no-op notes (ADR-0035 §4).
|
||||
("ddl.create_skipped_exists", &["name"]),
|
||||
("ddl.drop_skipped_absent", &["name"]),
|
||||
// Advanced-mode SQL CREATE INDEX / DROP INDEX no-op notes (ADR-0035 §4d).
|
||||
("ddl.create_index_skipped_exists", &["name"]),
|
||||
("ddl.drop_index_skipped_absent", &["name"]),
|
||||
("help.ddl.drop", &[]),
|
||||
("help.ddl.add", &[]),
|
||||
("help.ddl.rename", &[]),
|
||||
@@ -248,6 +253,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.create_table", &[]),
|
||||
("parse.usage.sql_create_table", &[]),
|
||||
("parse.usage.sql_drop_table", &[]),
|
||||
("parse.usage.sql_create_index", &[]),
|
||||
("parse.usage.sql_drop_index", &[]),
|
||||
("parse.usage.delete", &[]),
|
||||
("parse.usage.drop_column", &[]),
|
||||
("parse.usage.drop_constraint", &[]),
|
||||
|
||||
@@ -265,6 +265,11 @@ help:
|
||||
[, primary key (<col>, ...)]) — create a table (advanced SQL)
|
||||
sql_drop_table: |-
|
||||
drop table [if exists] <T> — remove a table (advanced SQL)
|
||||
sql_create_index: |-
|
||||
create [unique] index [if not exists] [<name>] on <T> (<col>, ...)
|
||||
— create an index (advanced SQL)
|
||||
sql_drop_index: |-
|
||||
drop index [if exists] <name> — remove an index (advanced SQL)
|
||||
drop: |-
|
||||
drop table <T> — remove a table
|
||||
drop column [from] [table] <T>: <col> [--cascade] — remove a column
|
||||
@@ -382,6 +387,12 @@ ddl:
|
||||
# `drop table if exists <T>` where the table is absent: a no-op that
|
||||
# succeeds with this note instead of a "doesn't exist" error.
|
||||
drop_skipped_absent: "table '{name}' doesn't exist — skipped (no changes made)"
|
||||
# `create [unique] index if not exists <name> …` where the index name
|
||||
# already exists: a no-op that succeeds with this note (ADR-0035 §4d).
|
||||
create_index_skipped_exists: "index '{name}' already exists — skipped (no changes made)"
|
||||
# `drop index if exists <name>` where the index is absent: a no-op that
|
||||
# succeeds with this note instead of a "doesn't exist" error.
|
||||
drop_index_skipped_absent: "index '{name}' doesn't exist — skipped (no changes made)"
|
||||
|
||||
parse:
|
||||
# Wrapper around chumsky's structural error message. The
|
||||
@@ -452,6 +463,8 @@ parse:
|
||||
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>"
|
||||
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
|
||||
sql_drop_index: "drop index [if exists] <Name>"
|
||||
drop_table: "drop table <Name>"
|
||||
drop_column: "drop column [from] [table] <Table>: <Name>"
|
||||
drop_relationship: |-
|
||||
|
||||
+29
-2
@@ -137,12 +137,15 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
}
|
||||
|
||||
// Indexes section (ADR-0025), shown only when the table
|
||||
// carries at least one user-created index.
|
||||
// carries at least one user-created index. A UNIQUE index is
|
||||
// marked `[unique]` so a learner can tell a uniqueness-enforcing
|
||||
// index from a performance-only one (ADR-0035 §4d).
|
||||
if !desc.indexes.is_empty() {
|
||||
out.push("Indexes:".to_string());
|
||||
for index in &desc.indexes {
|
||||
let unique = if index.unique { " [unique]" } else { "" };
|
||||
out.push(format!(
|
||||
" {} ({})",
|
||||
" {} ({}){unique}",
|
||||
index.name,
|
||||
index.columns.join(", "),
|
||||
));
|
||||
@@ -797,6 +800,30 @@ mod tests {
|
||||
let out = render_structure(&desc).join("\n");
|
||||
assert!(out.contains("Indexes:"), "got:\n{out}");
|
||||
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
|
||||
// A plain index carries no uniqueness marker.
|
||||
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_structure_marks_a_unique_index() {
|
||||
// ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
|
||||
// can tell it from a performance-only index.
|
||||
let desc = TableDescription {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![
|
||||
col("id", Type::Serial, true, false),
|
||||
col("Email", Type::Text, false, false),
|
||||
],
|
||||
outbound_relationships: Vec::new(),
|
||||
inbound_relationships: Vec::new(),
|
||||
indexes: vec![IndexInfo {
|
||||
name: "uidx_email".to_string(),
|
||||
columns: vec!["Email".to_string()],
|
||||
unique: true,
|
||||
}],
|
||||
};
|
||||
let out = render_structure(&desc).join("\n");
|
||||
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -186,6 +186,14 @@ pub struct IndexSchema {
|
||||
pub table: String,
|
||||
/// The indexed columns, in index order.
|
||||
pub columns: Vec<String>,
|
||||
/// Whether this is a `UNIQUE` index (ADR-0035 §4d — advanced-mode
|
||||
/// `CREATE UNIQUE INDEX`). The engine reports it via
|
||||
/// `pragma_index_list`'s `unique` column, so it is read back rather
|
||||
/// than stored in any `__rdbms_*` table; it is carried here so it
|
||||
/// round-trips through `project.yaml` and survives `rebuild`.
|
||||
/// Defaults to `false` when missing in older project files (the YAML
|
||||
/// field is optional on read); `version` stays `1`.
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -74,6 +74,12 @@ fn write_index(out: &mut String, index: &IndexSchema) {
|
||||
out.push_str("e_if_needed(col));
|
||||
}
|
||||
let _ = writeln!(out, "]");
|
||||
// Emit `unique` only when true (ADR-0035 §4d), matching the
|
||||
// column-`unique` convention — keeps pre-unique-index project files
|
||||
// byte-stable on a no-op round-trip.
|
||||
if index.unique {
|
||||
let _ = writeln!(out, " unique: true");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_table(out: &mut String, table: &TableSchema) {
|
||||
@@ -300,6 +306,7 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
name: i.name,
|
||||
table: i.table,
|
||||
columns: i.columns,
|
||||
unique: i.unique,
|
||||
})
|
||||
.collect();
|
||||
Ok(SchemaSnapshot {
|
||||
@@ -434,6 +441,11 @@ struct RawIndex {
|
||||
name: String,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
/// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project
|
||||
/// files written before unique indexes existed omit it and default
|
||||
/// to `false`.
|
||||
#[serde(default)]
|
||||
unique: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -479,6 +491,7 @@ mod tests {
|
||||
name: "Orders_CustId_idx".to_string(),
|
||||
table: "Orders".to_string(),
|
||||
columns: vec!["CustId".to_string()],
|
||||
unique: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -556,6 +569,89 @@ mod tests {
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_index_round_trips_through_yaml() {
|
||||
// ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize
|
||||
// → parse cycle. A plain index emits no `unique` line; a unique
|
||||
// index emits `unique: true`.
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
tables: vec![TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Serial,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "Email".to_string(),
|
||||
user_type: Type::Text,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
}],
|
||||
relationships: Vec::new(),
|
||||
indexes: vec![
|
||||
IndexSchema {
|
||||
name: "Customers_Email_uidx".to_string(),
|
||||
table: "Customers".to_string(),
|
||||
columns: vec!["Email".to_string()],
|
||||
unique: true,
|
||||
},
|
||||
IndexSchema {
|
||||
name: "Customers_id_idx".to_string(),
|
||||
table: "Customers".to_string(),
|
||||
columns: vec!["id".to_string()],
|
||||
unique: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let body = serialize_schema(&snap);
|
||||
// The unique index emits the flag; the plain one does not.
|
||||
assert!(body.contains("unique: true"), "yaml:\n{body}");
|
||||
assert_eq!(
|
||||
body.matches("unique: true").count(),
|
||||
1,
|
||||
"only the unique index carries the flag:\n{body}"
|
||||
);
|
||||
let parsed = parse_schema(&body).expect("parse schema");
|
||||
assert_eq!(parsed, snap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_without_unique_field_defaults_to_false() {
|
||||
// Older project files (written before unique indexes) omit the
|
||||
// `unique` field; the `#[serde(default)]` makes it `false`.
|
||||
let body = "\
|
||||
version: 1
|
||||
project:
|
||||
created_at: 2026-05-25T00:00:00Z
|
||||
tables:
|
||||
- name: Customers
|
||||
primary_key: [id]
|
||||
columns:
|
||||
- { name: id, type: serial }
|
||||
relationships: []
|
||||
indexes:
|
||||
- name: Customers_id_idx
|
||||
table: Customers
|
||||
columns: [id]
|
||||
";
|
||||
let parsed = parse_schema(body).expect("parse schema");
|
||||
assert_eq!(parsed.indexes.len(), 1);
|
||||
assert!(!parsed.indexes[0].unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_constraints_round_trip_through_yaml() {
|
||||
// NOT NULL / UNIQUE / DEFAULT survive a serialize →
|
||||
|
||||
+55
-8
@@ -29,9 +29,9 @@ use crate::action::Action;
|
||||
use crate::app::App;
|
||||
use crate::cli::Args;
|
||||
use crate::db::{
|
||||
AddColumnResult, ChangeColumnTypeResult, CreateOutcome, DataResult, Database, DbError,
|
||||
DeleteResult, DropColumnResult, DropOutcome, InsertResult, QueryPlan, TableDescription,
|
||||
UpdateResult,
|
||||
AddColumnResult, ChangeColumnTypeResult, CreateIndexOutcome, CreateOutcome, DataResult,
|
||||
Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
|
||||
QueryPlan, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::{Command, ColumnSpec};
|
||||
use crate::dsl::walker::Severity;
|
||||
@@ -1029,11 +1029,18 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
||||
// walker falls back to the schemaless value-literal list.
|
||||
for name in cache.tables.clone() {
|
||||
if let Ok(desc) = database.describe_table(name.clone(), None).await {
|
||||
// Per-table index names for the items panel (S2,
|
||||
// ADR-0025). Captured before `desc.columns` is
|
||||
// Per-table indexes for the items panel (S2, ADR-0025).
|
||||
// Carry uniqueness so the panel can mark a UNIQUE index
|
||||
// (ADR-0035 §4d). Captured before `desc.columns` is
|
||||
// consumed below.
|
||||
let index_names: Vec<String> =
|
||||
desc.indexes.iter().map(|i| i.name.clone()).collect();
|
||||
let index_entries: Vec<crate::completion::IndexEntry> = desc
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|i| crate::completion::IndexEntry {
|
||||
name: i.name.clone(),
|
||||
unique: i.unique,
|
||||
})
|
||||
.collect();
|
||||
let cols: Vec<TableColumn> = desc
|
||||
.columns
|
||||
.into_iter()
|
||||
@@ -1055,7 +1062,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
||||
})
|
||||
.collect();
|
||||
cache.table_columns.insert(name.clone(), cols);
|
||||
cache.table_indexes.insert(name, index_names);
|
||||
cache.table_indexes.insert(name, index_entries);
|
||||
}
|
||||
}
|
||||
cache
|
||||
@@ -1261,6 +1268,15 @@ fn spawn_dsl_dispatch(
|
||||
Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
|
||||
command: command.clone(),
|
||||
},
|
||||
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
|
||||
command: command.clone(),
|
||||
},
|
||||
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
|
||||
AppEvent::DslCreateIndexSkipped {
|
||||
command: command.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
||||
command: command.clone(),
|
||||
data,
|
||||
@@ -1662,6 +1678,15 @@ enum CommandOutcome {
|
||||
/// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
|
||||
/// renders the "doesn't exist — skipped" note from the command.
|
||||
SchemaDropSkipped,
|
||||
/// A SQL `DROP INDEX IF EXISTS` that matched no index — a no-op
|
||||
/// (ADR-0035 §4d). The App renders the "doesn't exist — skipped"
|
||||
/// note from the command's index name.
|
||||
SchemaDropIndexSkipped,
|
||||
/// A SQL `CREATE INDEX IF NOT EXISTS` that matched an existing index
|
||||
/// name — a no-op (ADR-0035 §4d). Carries the resolved index name
|
||||
/// (the auto-name is unknown to the command) for the "already exists
|
||||
/// — skipped" note.
|
||||
SchemaCreateIndexSkipped(String),
|
||||
Query(DataResult),
|
||||
QueryPlan(QueryPlan),
|
||||
Insert(InsertResult),
|
||||
@@ -2048,6 +2073,28 @@ async fn execute_command_typed(
|
||||
.drop_index(selector, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::SqlDropIndex { name, if_exists } => database
|
||||
.sql_drop_index(name, if_exists, src)
|
||||
.await
|
||||
.map(|outcome| match outcome {
|
||||
// Auto-show the now de-indexed table (ADR-0014), unlike
|
||||
// SQL DROP TABLE whose table is gone.
|
||||
DropIndexOutcome::Dropped(d) => CommandOutcome::Schema(Some(d)),
|
||||
DropIndexOutcome::Skipped => CommandOutcome::SchemaDropIndexSkipped,
|
||||
}),
|
||||
Command::SqlCreateIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
} => database
|
||||
.sql_create_index(name, table, columns, unique, if_not_exists, src)
|
||||
.await
|
||||
.map(|outcome| match outcome {
|
||||
CreateIndexOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
|
||||
CreateIndexOutcome::Skipped(n) => CommandOutcome::SchemaCreateIndexSkipped(n),
|
||||
}),
|
||||
Command::AddConstraint {
|
||||
table,
|
||||
column,
|
||||
|
||||
@@ -514,8 +514,11 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
||||
lines.push(Line::from(Span::styled(name.as_str(), style)));
|
||||
if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
|
||||
for index in indexes {
|
||||
// Mark a UNIQUE index so the panel distinguishes it from
|
||||
// a performance-only index (ADR-0035 §4d).
|
||||
let unique = if index.unique { " [unique]" } else { "" };
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {index}"),
|
||||
format!(" {}{unique}", index.name),
|
||||
Style::default().fg(theme.muted),
|
||||
)));
|
||||
}
|
||||
@@ -1280,17 +1283,23 @@ mod tests {
|
||||
#[test]
|
||||
fn items_panel_nests_indexes_under_their_table() {
|
||||
// S2 (ADR-0025): the items panel renders each table
|
||||
// with its index names indented beneath it.
|
||||
// with its index names indented beneath it. A UNIQUE index is
|
||||
// marked `[unique]` (ADR-0035 §4d).
|
||||
use crate::completion::IndexEntry;
|
||||
let mut app = App::new();
|
||||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||||
app.schema_cache.table_indexes.insert(
|
||||
"Customers".to_string(),
|
||||
vec!["idx_email".to_string()],
|
||||
vec![
|
||||
IndexEntry { name: "idx_email".to_string(), unique: false },
|
||||
IndexEntry { name: "uidx_login".to_string(), unique: true },
|
||||
],
|
||||
);
|
||||
let theme = Theme::dark();
|
||||
let out = render_to_string(&mut app, &theme, 80, 24);
|
||||
assert!(out.contains("Customers"), "table listed:\n{out}");
|
||||
assert!(out.contains("Orders"), "table listed:\n{out}");
|
||||
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
|
||||
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user