//! SQLite database access via an async worker. //! //! The application talks to SQLite through a single //! request/response channel. A dedicated OS thread owns the //! `rusqlite::Connection` (which is `Send` but `!Sync` and uses //! a synchronous API), receives `Request` messages, and replies //! on per-request `oneshot` channels. //! //! This shape was chosen up front in Phase 2 of the parser/DB //! iteration so that B3 (query timeout/cancellation) and U1 //! (snapshot capture) drop in without an architectural refactor. //! //! ## STRICT and foreign keys //! //! Per ADR-0002, every table is created with the `STRICT` //! keyword and the connection-level `PRAGMA foreign_keys` is //! enabled at open time. //! //! ## Error handling //! //! Database errors flow through `DbError`, which carries a //! coarse `kind` to support the future friendly-error layer //! (H1). For now `friendly_message()` is a passthrough; when H1 //! lands the body of that method becomes the translation table. use std::path::Path; use std::thread; use rusqlite::Connection; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, Operand, Predicate, RelationshipSelector, RowFilter, SqlForeignKey, }; use crate::dsl::ColumnSpec; use crate::dsl::shortid; use crate::dsl::types::Type; use crate::dsl::value::{Bound, Value, ValueError}; use crate::output_render::{Alignment, render_diagnostic_table}; use crate::type_change; use crate::persistence::{ CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, }; use crate::project::{DATA_DIR, PROJECT_YAML}; use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged}; /// Inbox capacity. The worker is fast enough that this rarely /// matters; `64` is a generous head-room for bursts. const REQUEST_CHANNEL_CAPACITY: usize = 64; /// In-process handle for the database. Cheap to clone. #[derive(Debug, Clone)] pub struct Database { inbox: mpsc::Sender, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TableDescription { pub name: String, pub columns: Vec, /// Relationships where *this* table is the child (holds the /// FK column referencing another table). pub outbound_relationships: Vec, /// Relationships where *this* table is the parent (some /// other table's column references one of ours). pub inbound_relationships: Vec, /// User-created indexes on this table (ADR-0025). pub indexes: Vec, /// Table-level composite `UNIQUE (a, b, …)` constraints (ADR-0035 /// §4a.2). Single-column UNIQUE rides on the column itself /// (`ColumnDescription::unique`); these are the multi-column ones. pub unique_constraints: Vec>, /// Table-level `CHECK (…)` constraints with their optional name /// (ADR-0035 §4a.3 / §4g), in declaration order. pub check_constraints: Vec, } /// One user-created index on a table (ADR-0025). /// /// Read live from the engine's native catalog /// (`pragma_index_list` / `pragma_index_info`); the playground /// keeps no separate index metadata table. Only indexes with /// origin `c` (a `CREATE INDEX` statement) are surfaced — the /// automatic indexes backing primary keys and UNIQUE /// constraints are not user indexes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexInfo { pub name: String, /// Indexed columns, in index order. pub columns: Vec, pub unique: bool, } /// One end of a relationship as seen from the table being /// described. /// /// Used for both outbound (this table is the child, holding the /// FK column) and inbound (this table is the parent being /// referenced) sides; the field meanings flip per side. /// `(outbound, inbound)` pair returned by /// [`Database::read_relationships`]. pub type RelationshipsReply = Result<(Vec, Vec), DbError>; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelationshipEnd { /// User-facing name of the relationship (auto-generated or /// user-supplied at creation time). pub name: String, /// The other table involved. pub other_table: String, /// The column on the other table. pub other_column: String, /// The column on *this* table. pub local_column: String, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColumnDescription { pub name: String, /// The user-facing type the column was declared as, recovered /// from our internal column-metadata table. Always populated /// for tables created through the DSL. `None` only for the /// edge case of a foreign-attached database whose tables we /// did not create — not achievable in the current flow. pub user_type: Option, /// The SQLite-side type as reported by `PRAGMA table_info` /// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a /// fall-back when `user_type` is not available; the UI /// prefers `user_type` when rendering. pub sqlite_type: String, pub notnull: bool, pub primary_key: bool, /// Carries a single-column `UNIQUE` constraint (ADR-0029). /// A PK column is not flagged here — the `primary_key` /// flag already conveys its implicit uniqueness. pub unique: bool, /// The column's `DEFAULT` expression as SQLite reports it, /// or `None` (ADR-0029). pub default: Option, /// The column's `CHECK` constraint in compiled-SQL form, /// or `None` (ADR-0029). pub check: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DbError { Sqlite { message: String, kind: SqliteErrorKind, }, Unsupported(String), InvalidValue(String), PersistenceFatal { operation: &'static str, path: std::path::PathBuf, message: String, }, RebuildRowFailed { table: String, csv_path: std::path::PathBuf, row_number: usize, detail: String, }, WorkerGone, Io(String), } impl std::fmt::Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Sqlite { message, .. } => f.write_str(&crate::t!( "db.error.sqlite", message = message, )), Self::Unsupported(detail) => f.write_str(&crate::t!( "db.error.unsupported", detail = detail, )), Self::InvalidValue(detail) => f.write_str(&crate::t!( "db.error.invalid_value", detail = detail, )), Self::PersistenceFatal { operation, path, message, } => f.write_str(&crate::t!( "db.error.persistence_fatal", operation = operation, path = path.display(), message = message, )), Self::RebuildRowFailed { table, csv_path, row_number, detail, } => f.write_str(&crate::t!( "db.error.rebuild_row_failed", row_number = row_number, csv_path = csv_path.display(), table = table, detail = detail, )), Self::WorkerGone => f.write_str(&crate::t!("db.error.worker_gone")), Self::Io(detail) => f.write_str(&crate::t!( "db.error.io", detail = detail, )), } } } impl std::error::Error for DbError {} /// Result of a query / show-data call. /// /// `None` cells render as NULL; `Some(s)` renders as the /// string. `column_types` carries the user-facing type per /// column (per ADR-0016 §2): the renderer uses it for /// alignment, and future work uses it for type-aware cell /// styling. `None` only for the edge case of a /// foreign-attached database we did not create — not /// achievable in normal use. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataResult { pub table_name: String, pub columns: Vec, pub column_types: Vec>, pub rows: Vec>>, } /// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2). /// /// `id` / `parent` form the plan tree (`parent == 0` is a /// top-level node); `detail` is the engine's verbatim step /// description (`SCAN Customers`, `SEARCH … USING INDEX …`). /// The `notused` column the engine also returns is dropped. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExplainRow { pub id: i64, pub parent: i64, pub detail: String, } /// A captured query plan (ADR-0028 §2/§3). /// /// `display_sql` is the standard-SQL form of the explained /// statement — shown above the plan tree as part of the /// simple → advanced bridge. `rows` are the plan-tree nodes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct QueryPlan { pub display_sql: String, pub rows: Vec, } /// Outcome of a successful INSERT — a count plus the new row(s) /// fetched immediately after so the user can see what landed /// (auto-filled IDs, generated shortids, etc.). #[derive(Debug, Clone, PartialEq, Eq)] pub struct InsertResult { pub rows_affected: usize, pub data: DataResult, } /// Outcome of a successful `add column …`. /// /// Carries the post-add structure (used for the auto-show that /// follows DDL) plus zero or one pre-rendered `[client-side]` /// note lines (ADR-0018 §9). Only the auto-fill paths /// (`add column T: x (serial|shortid)` on a non-empty table) /// produce notes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AddColumnResult { pub description: TableDescription, pub client_side_notes: Vec, } /// Outcome of a successful `drop column …` (ADR-0025). /// /// `dropped_indexes` names any index removed by `--cascade` /// because it covered the dropped column. Empty in the common /// case (no covering index, or none to cascade); the runtime /// renders one note line per entry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DropColumnResult { pub description: TableDescription, pub dropped_indexes: Vec, } /// Outcome of a successful `change column …` (ADR-0017 §6). /// /// `description` is the post-rebuild table structure (used for /// the auto-show that follows DDL). `client_side` carries the /// pedagogical note that fires whenever the playground rewrote /// any cell value before storing it — the moment that tells the /// learner "the tool did this for you; raw SQL would need a /// `CAST` or application code." `None` when the change was /// pure metadata (storage class unchanged for every row). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChangeColumnTypeResult { pub description: TableDescription, pub client_side: Option, } /// Counts feeding the `[client-side]` success line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ClientSideNote { /// Cells whose stored value differs from what the user /// originally inserted (any non-identity transformation, /// including pure storage-class changes like /// `Text("42")` → `Integer(42)`). Auto-filled cells (per /// ADR-0018) are NOT counted here — they're tracked in /// `auto_filled` separately so the success summary can /// distinguish "the tool transformed your data" from "the /// tool generated values where you had nulls." pub transformed: usize, /// Subset of `transformed` where information was discarded /// (only non-zero when `--force-conversion` was used). pub lossy: usize, /// Null cells filled with auto-generated values when the /// target type carries an auto-generation contract /// (`serial` / `shortid`). ADR-0018 §3 / §7. pub auto_filled: usize, /// What kind of value was auto-generated; drives the note /// wording (per ADR-0018 §9). `None` when `auto_filled` is /// zero. pub auto_fill_kind: Option, } /// Whether an auto-fill emitted serial sequence values or /// generated shortids. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AutoFillKind { Serial, ShortId, } /// Outcome of a successful UPDATE — a count plus the rows that /// matched (and were updated). Captured by rowid so that even an /// UPDATE which changes the WHERE-target column still finds the /// post-update rows. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateResult { pub rows_affected: usize, pub data: DataResult, } /// Outcome of a successful DELETE — the directly-deleted-row /// count plus any cascade effects observed in child tables. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteResult { pub rows_affected: usize, pub cascade: Vec, /// Rows produced by a `RETURNING` clause (ADR-0033 §5, 3g). /// Empty (no columns, no rows) when the DELETE had no /// `RETURNING` — the renderer skips a column-less result, so the /// non-RETURNING path is unaffected. For SQL `DELETE … RETURNING` /// these are the rows as they were *before* deletion; the /// cascade summary surfaces alongside. pub data: DataResult, } /// One observed change in a child table caused by referential /// action on a parent-side DELETE. Detected by row-count diffing /// the child table immediately before and after the delete. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CascadeEffect { pub relationship_name: String, pub child_table: String, pub rows_changed: i64, pub action: ReferentialAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SqliteErrorKind { /// `UNIQUE` constraint, including duplicate primary key. UniqueViolation, /// Referenced or operated-on table does not exist. NoSuchTable, /// Operated-on column does not exist. NoSuchColumn, /// Object (table, index, etc.) already exists. AlreadyExists, /// Catch-all. Other, } impl DbError { /// User-visible rendering of this error. /// /// Routes through the H1 friendly-error layer /// ([`crate::friendly::translate_error`], ADR-0019). With /// no context the translator falls back to abstract wording /// — for operation-tailored output, callers should /// construct a [`crate::friendly::TranslateContext`] and /// call the translator directly. This method exists for the /// many callsites where context isn't readily available /// (fatal-banner paths, fallback render paths) and the /// abstract wording is acceptable. #[must_use] pub fn friendly_message(&self) -> String { let ctx = crate::friendly::TranslateContext::default(); crate::friendly::translate_error(self, &ctx).render() } fn from_rusqlite(err: rusqlite::Error) -> Self { let message = err.to_string(); let kind = classify_sqlite_error(&err, &message); Self::Sqlite { message, kind } } fn from_persistence(err: PersistenceError) -> Self { Self::PersistenceFatal { operation: err.operation(), path: err.path().to_path_buf(), message: err.to_string(), } } /// Whether this error means the application cannot /// continue and must quit (per ADR-0015 §8). The runtime /// surfaces these as fatal banners. #[must_use] pub const fn is_fatal(&self) -> bool { matches!(self, Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. }) } } fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind { use rusqlite::ErrorCode; if let rusqlite::Error::SqliteFailure(code, _) = err && code.code == ErrorCode::ConstraintViolation { return SqliteErrorKind::UniqueViolation; } let lowered = message.to_ascii_lowercase(); if lowered.contains("no such table") { SqliteErrorKind::NoSuchTable } else if lowered.contains("no such column") { SqliteErrorKind::NoSuchColumn } else if lowered.contains("already exists") { SqliteErrorKind::AlreadyExists } else { SqliteErrorKind::Other } } /// Internal request type — kept private so the channel protocol /// is not part of the public API. #[derive(Debug)] enum Request { CreateTable { name: String, columns: Vec, primary_key: Vec, source: Option, reply: oneshot::Sender>, }, /// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes /// structurally through `do_create_table`; `if_not_exists` turns /// an existing table into a no-op (`CreateOutcome::Skipped`, no /// snapshot) instead of an error (ADR-0035 §4). SqlCreateTable { name: String, columns: Vec, primary_key: Vec, unique_constraints: Vec>, check_constraints: Vec, foreign_keys: Vec, if_not_exists: bool, source: Option, reply: oneshot::Sender>, }, DropTable { name: String, source: Option, reply: oneshot::Sender>, }, /// 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, reply: oneshot::Sender>, }, /// Advanced-mode SQL `DROP INDEX [IF EXISTS] ` (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, reply: oneshot::Sender>, }, /// 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, table: String, columns: Vec, unique: bool, if_not_exists: bool, source: Option, reply: oneshot::Sender>, }, AddColumn { table: String, column: ColumnSpec, source: Option, reply: oneshot::Sender>, }, DropColumn { table: String, column: String, cascade: bool, source: Option, reply: oneshot::Sender>, }, RenameColumn { table: String, old: String, new: String, source: Option, reply: oneshot::Sender>, }, /// `ALTER TABLE RENAME TO ` (ADR-0035 §6, 4h). RenameTable { table: String, new: String, source: Option, reply: oneshot::Sender>, }, ChangeColumnType { table: String, column: String, ty: Type, mode: ChangeColumnMode, source: Option, reply: oneshot::Sender>, }, ListTables { reply: oneshot::Sender, DbError>>, }, DescribeTable { name: String, source: Option, reply: oneshot::Sender>, }, AddRelationship { name: Option, parent_table: String, parent_column: String, child_table: String, child_column: String, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, source: Option, reply: oneshot::Sender>, }, DropRelationship { selector: RelationshipSelector, source: Option, reply: oneshot::Sender, DbError>>, }, /// Create an index on a table (ADR-0025). AddIndex { name: Option, table: String, columns: Vec, source: Option, reply: oneshot::Sender>, }, /// Drop an index by name or by table + column set (ADR-0025). DropIndex { selector: IndexSelector, source: Option, reply: oneshot::Sender>, }, /// Add a column-level constraint to an existing column /// (ADR-0029 §2.2). The post-rebuild `TableDescription` /// flows back through the standard auto-show path. AddConstraint { table: String, column: String, constraint: Constraint, source: Option, reply: oneshot::Sender>, }, /// Remove a column-level constraint from an existing column /// (ADR-0029 §2.2). DropConstraint { table: String, column: String, kind: ConstraintKind, source: Option, reply: oneshot::Sender>, }, /// `ALTER TABLE … ADD [CONSTRAINT ] CHECK ()` — a /// table-level CHECK, named or unnamed (ADR-0035 §4g). AlterAddTableCheck { table: String, name: Option, expr_sql: String, source: Option, reply: oneshot::Sender>, }, /// `ALTER TABLE … ADD UNIQUE (, …)` — a composite UNIQUE /// constraint (ADR-0035 §4g). AlterAddUnique { table: String, columns: Vec, source: Option, reply: oneshot::Sender>, }, /// `ALTER TABLE … DROP CONSTRAINT ` — drop a named table-level /// CHECK or a named FK (ADR-0035 §4g). AlterDropConstraint { table: String, name: String, source: Option, reply: oneshot::Sender, DbError>>, }, /// `ALTER TABLE ADD [CONSTRAINT ] FOREIGN KEY (…) /// REFERENCES …` — a relationship on an existing table (ADR-0035 §4g). AlterAddForeignKey { child_table: String, name: Option, fk: Box, source: Option, reply: oneshot::Sender>, }, Insert { table: String, columns: Option>, values: Vec, source: Option, reply: oneshot::Sender>, }, Update { table: String, assignments: Vec<(String, Value)>, filter: RowFilter, source: Option, reply: oneshot::Sender>, }, Delete { table: String, filter: RowFilter, source: Option, reply: oneshot::Sender>, }, QueryData { table: String, filter: Option, limit: Option, source: Option, reply: oneshot::Sender>, }, /// Run a SQL `SELECT` typed by the user in advanced mode /// (ADR-0030 §6, ADR-0031). The grammar walker has already /// validated `sql` is in the supported subset; the worker /// prepares and runs the statement and returns the rows as /// a [`DataResult`] (with no playground type information per /// ADR-0030 §6 — computed columns render with neutral /// alignment). `source` is the literal submitted line, /// appended to `history.log` for replay (ADR-0030 §11). RunSelect { sql: String, source: Option, reply: oneshot::Sender>, }, /// Run a validated SQL `INSERT` typed in advanced mode /// (ADR-0033 §1, sub-phase 3b). The grammar walker has /// validated `sql` is in the supported subset; the worker /// executes it as text, re-persists the target table's CSV /// (ADR-0030 §11), and appends the literal line to /// `history.log`. `target_table` comes from the parse so the /// worker re-persists the right CSV without re-parsing. RunSqlInsert { sql: String, source: Option, target_table: String, listed_columns: Vec, row_source: String, returning: bool, /// Captured literal `VALUES` (per row, per position; `None` = /// expression) for app-level type validation before the verbatim /// insert (ADR-0036 Phase 1). literal_rows: Vec>>, reply: oneshot::Sender>, }, /// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The /// worker executes `sql` as text, re-persists `target_table`'s /// CSV (ADR-0030 §11), and appends the literal line to /// `history.log`. RunSqlUpdate { sql: String, source: Option, target_table: String, returning: bool, /// Captured literal `SET col = ` values (`(col, None)` = /// expression RHS) for app-level type validation before the /// verbatim update (ADR-0036 Phase 2). set_literals: Vec<(String, Option)>, reply: oneshot::Sender>, }, /// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The /// worker executes `sql` as text, detects FK cascade by /// row-count diffing the inbound children (Amendment 2), /// re-persists `target_table`'s CSV plus every cascade-affected /// child (ADR-0030 §11), and appends the literal line to /// `history.log`. RunSqlDelete { sql: String, source: Option, target_table: String, returning: bool, reply: oneshot::Sender>, }, /// Capture the query plan for an explainable command via /// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner /// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN` /// never executes it, so this is read-only even for the /// destructive variants. ExplainPlan { query: Command, reply: oneshot::Sender>, }, /// Rebuild the database from `project.yaml` + `data/` /// (ADR-0015 §7). Used by the runtime when the `.db` file /// is missing on project open and by the explicit /// `rebuild` app-level command (Iteration 4). RebuildFromText { project_path: std::path::PathBuf, source: Option, reply: oneshot::Sender>, }, /// Read both directions of FK relationships for `table`. /// Returns `(outbound, inbound)` — outbound = rows where /// `table` is the child (has FK columns pointing elsewhere); /// inbound = rows where `table` is the parent (referenced /// by other tables). Used by the friendly-error layer's /// runtime enrichment (ADR-0019 §6). ReadRelationships { table: String, reply: oneshot::Sender, }, /// Find rows in `table` where `column` matches `value`. /// Capped at `limit` rows. Used by the friendly-error /// layer's row-pinpoint diagnostic (ADR-0019 §6, ADR-0017 §7). /// Best-effort: returns empty rows on any failure (no row /// matched, schema gone, type mismatch on bind, etc.). FindRowsMatching { table: String, column: String, value: Value, limit: usize, reply: oneshot::Sender>, }, /// List schema entity names for a given identifier slot /// (ADR-0022 §9). Used by the completion engine to offer /// candidates for `TableName` / `Column` / /// `RelationshipName` slots. `NewName` is rejected at /// the caller — schema has nothing to offer for new /// names — and never reaches the worker. /// /// Returns names in stable (alphabetical) order, no /// duplicates. The reply is small even for projects with /// hundreds of tables/columns. ListNamesFor { source: crate::dsl::grammar::IdentSource, reply: oneshot::Sender, DbError>>, }, /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// Replies with the metadata of the command that was undone, or /// `None` if there is nothing to undo (or undo is disabled). Undo { reply: oneshot::Sender, DbError>>, }, /// Re-apply the most recently undone snapshot. `None` if there is /// nothing to redo. Redo { reply: oneshot::Sender, DbError>>, }, /// Read — without restoring — the snapshot `undo` would restore. /// Used to build the confirmation prompt. `None` if the ring is /// empty or undo is disabled. PeekUndo { reply: oneshot::Sender, DbError>>, }, /// Read — without restoring — the snapshot `redo` would restore. PeekRedo { reply: oneshot::Sender, DbError>>, }, /// Open a batch (ADR-0006 Amendment 1): take one boundary /// snapshot for the whole batch and suppress per-command /// snapshots until `EndBatch`. Used by `replay` so a multi-command /// replay is a single undo step. `source` is the batch command. BeginBatch { source: Option, reply: oneshot::Sender<()>, }, /// Close a batch: finalise the boundary snapshot into the ring if /// any mutation committed during the batch, else discard it. EndBatch { reply: oneshot::Sender<()>, }, } impl Database { /// Open a database without per-command persistence. /// /// The path may be a filesystem location or `":memory:"` /// for an ephemeral in-memory database. The connection is /// moved onto a dedicated worker thread. With no /// persistence handle, the YAML/CSV/`history.log` writes /// are skipped — useful for unit tests that exercise the /// SQLite layer in isolation. pub fn open>(path: P) -> Result { Self::open_inner(path, None, false) } /// Open a database with per-command persistence wired in /// (ADR-0015 §6). Every successful user-issued mutation /// writes through to `project.yaml`, the affected /// `data/
.csv` files, and `history.log` *before* /// the SQLite tx commits. pub fn open_with_persistence>( path: P, persistence: Persistence, ) -> Result { Self::open_inner(path, Some(persistence), false) } /// Open with per-command persistence *and* the undo/snapshot ring /// (ADR-0006 Amendment 1). `undo_enabled` is `false` under the /// `--no-undo` CLI flag, in which case no snapshots are taken. pub fn open_with_persistence_and_undo>( path: P, persistence: Persistence, undo_enabled: bool, ) -> Result { Self::open_inner(path, Some(persistence), undo_enabled) } fn open_inner>( path: P, persistence: Option, undo_enabled: bool, ) -> Result { let path_display = path.as_ref().to_string_lossy().into_owned(); let conn = match path.as_ref().to_str() { Some(":memory:") => Connection::open_in_memory(), _ => Connection::open(path.as_ref()), } .map_err(DbError::from_rusqlite)?; info!(path = %path_display, "opened database"); configure_connection(&conn).map_err(DbError::from_rusqlite)?; // The undo ring needs the project directory; it is only // available when persistence is wired and undo is enabled. let snapshots = if undo_enabled { persistence .as_ref() .map(|p| SnapshotStore::new(p.project_path(), DEFAULT_RING_CAPACITY)) } else { None }; if let Some(store) = &snapshots { // Sweep crash leftovers (`.staging/`, orphan payloads). if let Err(e) = store.cleanup() { warn!(error = %e, "undo snapshot cleanup on open failed"); } info!("undo snapshots enabled"); } let (tx, rx) = mpsc::channel::(REQUEST_CHANNEL_CAPACITY); thread::Builder::new() .name("rdbms-db-worker".to_string()) .spawn(move || worker_loop(conn, persistence, snapshots, rx)) .map_err(|e| DbError::Io(e.to_string()))?; Ok(Self { inbox: tx }) } pub async fn create_table( &self, name: String, columns: Vec, primary_key: Vec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::CreateTable { name, columns, primary_key, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes /// structurally; returns whether the table was created or skipped /// (the `IF NOT EXISTS` no-op, ADR-0035 §4). #[allow(clippy::too_many_arguments)] pub async fn sql_create_table( &self, name: String, columns: Vec, primary_key: Vec, unique_constraints: Vec>, check_constraints: Vec, foreign_keys: Vec, if_not_exists: bool, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::SqlCreateTable { name, columns, primary_key, unique_constraints, check_constraints, foreign_keys, if_not_exists, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn drop_table(&self, name: String, source: Option) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::DropTable { name, source, reply }).await?; 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, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::SqlDropTable { name, if_exists, source, reply, }) .await?; 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, ) -> Result { 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, table: String, columns: Vec, unique: bool, if_not_exists: bool, source: Option, ) -> Result { 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, column: ColumnSpec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AddColumn { table, column, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn drop_column( &self, table: String, column: String, cascade: bool, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::DropColumn { table, column, cascade, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn add_index( &self, name: Option, table: String, columns: Vec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AddIndex { name, table, columns, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn drop_index( &self, selector: IndexSelector, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::DropIndex { selector, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Add a column-level constraint to an existing column /// (ADR-0029 §2.2). pub async fn add_constraint( &self, table: String, column: String, constraint: Constraint, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AddConstraint { table, column, constraint, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Remove a column-level constraint from an existing column /// (ADR-0029 §2.2). pub async fn drop_constraint( &self, table: String, column: String, kind: ConstraintKind, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::DropConstraint { table, column, kind, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// `ALTER TABLE … ADD [CONSTRAINT ] CHECK ()` — a /// table-level CHECK (ADR-0035 §4g). pub async fn alter_add_table_check( &self, table: String, name: Option, expr_sql: String, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AlterAddTableCheck { table, name, expr_sql, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// `ALTER TABLE … ADD UNIQUE (, …)` — a composite UNIQUE /// constraint (ADR-0035 §4g). pub async fn alter_add_unique( &self, table: String, columns: Vec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AlterAddUnique { table, columns, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// `ALTER TABLE … DROP CONSTRAINT ` — drop a named table-level /// CHECK or a named FK (ADR-0035 §4g). pub async fn alter_drop_constraint( &self, table: String, name: String, source: Option, ) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::AlterDropConstraint { table, name, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// `ALTER TABLE ADD [CONSTRAINT ] FOREIGN KEY (…) /// REFERENCES …` — add a relationship to an existing table (ADR-0035 /// §4g). pub async fn alter_add_foreign_key( &self, child_table: String, name: Option, fk: SqlForeignKey, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AlterAddForeignKey { child_table, name, fk: Box::new(fk), source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn rename_column( &self, table: String, old: String, new: String, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RenameColumn { table, old, new, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// `ALTER TABLE
RENAME TO ` (ADR-0035 §6, 4h). pub async fn rename_table( &self, table: String, new: String, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RenameTable { table, new, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn change_column_type( &self, table: String, column: String, ty: Type, mode: ChangeColumnMode, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::ChangeColumnType { table, column, mode, ty, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn list_tables(&self) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::ListTables { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn describe_table( &self, name: String, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::DescribeTable { name, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } #[allow(clippy::too_many_arguments)] pub async fn add_relationship( &self, name: Option, parent_table: String, parent_column: String, child_table: String, child_column: String, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::AddRelationship { name, parent_table, parent_column, child_table, child_column, on_delete, on_update, create_fk, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn drop_relationship( &self, selector: RelationshipSelector, source: Option, ) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::DropRelationship { selector, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn insert( &self, table: String, columns: Option>, values: Vec, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::Insert { table, columns, values, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn update( &self, table: String, assignments: Vec<(String, Value)>, filter: RowFilter, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::Update { table, assignments, filter, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn delete( &self, table: String, filter: RowFilter, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::Delete { table, filter, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Rebuild the database from `project.yaml` + `data/` /// (ADR-0015 §7). /// /// Called by the runtime on a missing `.db` at startup /// (with `source = None`, no history entry) and by the /// explicit `rebuild` app-level command (with /// `source = Some("rebuild")`, which appends to /// `history.log` on success). pub async fn rebuild_from_text( &self, project_path: std::path::PathBuf, source: Option, ) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::RebuildFromText { project_path, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } pub async fn query_data( &self, table: String, filter: Option, limit: Option, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::QueryData { table, filter, limit, source, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Run a validated SQL `SELECT` and return the rows /// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated /// statement text; `source` is the literal submitted line /// for `history.log`. pub async fn run_select( &self, sql: String, source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RunSelect { sql, source, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Run a validated SQL `INSERT` and return the affected-row /// count plus the inserted rows (ADR-0033 §1, sub-phase 3b). /// `sql` is the grammar-validated statement text; `source` is /// the literal submitted line for `history.log`; `target_table` /// is the parsed target whose CSV is re-persisted. /// Run a grammar-validated SQL `INSERT` with **no** captured literals /// (no app-level value validation — the verbatim ADR-0033 path). Used /// by worker-level callers that build the statement directly. The /// runtime, which has a parsed command, uses /// [`Self::run_sql_insert_with_literals`] instead so the literals are /// validated (ADR-0036 Phase 1). pub async fn run_sql_insert( &self, sql: String, source: Option, target_table: String, listed_columns: Vec, row_source: String, returning: bool, ) -> Result { self.run_sql_insert_with_literals( sql, source, target_table, listed_columns, row_source, returning, Vec::new(), ) .await } /// As [`Self::run_sql_insert`], plus the literal `VALUES` captured at /// parse (per row, per position; `None` = expression) so the worker /// can validate each literal against its column type before the /// (still verbatim) insert and the error layer can name the offending /// value (ADR-0036 Phase 1). #[allow(clippy::too_many_arguments)] pub async fn run_sql_insert_with_literals( &self, sql: String, source: Option, target_table: String, listed_columns: Vec, row_source: String, returning: bool, literal_rows: Vec>>, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RunSqlInsert { sql, source, target_table, listed_columns, row_source, returning, literal_rows, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Run a validated SQL `UPDATE` with **no** captured literals (no /// app-level value validation — the verbatim ADR-0033 path). Used by /// worker-level callers that build the statement directly. The /// runtime, which has a parsed command, uses /// [`Self::run_sql_update_with_literals`] instead so the `SET` /// literals are validated (ADR-0036 Phase 2). `sql` is the /// grammar-validated statement text; `source` is the literal /// submitted line for `history.log`; `target_table` is the parsed /// target whose CSV is re-persisted. pub async fn run_sql_update( &self, sql: String, source: Option, target_table: String, returning: bool, ) -> Result { self.run_sql_update_with_literals(sql, source, target_table, returning, Vec::new()) .await } /// As [`Self::run_sql_update`], plus the literal `SET` values captured /// at parse (`(col, None)` = expression RHS) so the worker can /// validate each literal against its column type before the (still /// verbatim) update and the error layer can name the offending value /// (ADR-0036 Phase 2). pub async fn run_sql_update_with_literals( &self, sql: String, source: Option, target_table: String, returning: bool, set_literals: Vec<(String, Option)>, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RunSqlUpdate { sql, source, target_table, returning, set_literals, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Run a validated SQL `DELETE` and return the affected-row /// count plus any cascade effects (ADR-0033 §1/§7, sub-phase /// 3f). `sql` is the grammar-validated statement text; `source` /// is the literal submitted line for `history.log`; /// `target_table` is the parsed target whose CSV (and whose /// cascade-affected children's CSVs) are re-persisted. pub async fn run_sql_delete( &self, sql: String, source: Option, target_table: String, returning: bool, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::RunSqlDelete { sql, source, target_table, returning, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Capture the query plan for an explainable command /// (ADR-0028 §2). The wrapped command is not executed — /// `EXPLAIN QUERY PLAN` only inspects how the engine would /// locate the rows — so this is safe even for `update` / /// `delete`. pub async fn explain_query_plan( &self, query: Command, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::ExplainPlan { query, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Read both directions of FK relationships for `table`. /// Used by the runtime's friendly-error enrichment to /// resolve parent / child table names (ADR-0019 §6). pub async fn read_relationships( &self, table: String, ) -> RelationshipsReply { let (reply, recv) = oneshot::channel(); self.send(Request::ReadRelationships { table, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Pinpoint rows in `table` where `column` matches `value`. /// Used by the runtime's friendly-error enrichment to /// surface offending rows after a UNIQUE / FK violation /// (ADR-0019 §6, ADR-0017 §7). Capped at `limit`. pub async fn find_rows_matching( &self, table: String, column: String, value: Value, limit: usize, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::FindRowsMatching { table, column, value, limit, reply, }) .await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// List schema entity names for an identifier source /// (ADR-0022 §9, ADR-0024 §architecture). /// /// Returns alphabetised, deduplicated names suitable for /// the completion menu: /// - `IdentSource::Tables` → user tables (filters /// `__rdbms_*` internal tables); /// - `IdentSource::Columns` → distinct column names /// across all user tables (v1 simplification — no /// table-context binding); /// - `IdentSource::Relationships` → relationship names /// from the metadata table; /// - `IdentSource::NewName`, `Types`, `Free` → returns /// `Ok(vec![])` immediately without a worker round-trip /// (the user invents these names, or the source is /// synthetic). pub async fn list_names_for( &self, source: crate::dsl::grammar::IdentSource, ) -> Result, DbError> { if !source.completes_from_schema() { return Ok(Vec::new()); } let (reply, recv) = oneshot::channel(); self.send(Request::ListNamesFor { source, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// `Ok(Some(meta))` reports the command that was undone; /// `Ok(None)` means nothing to undo (or undo is disabled). pub async fn undo(&self) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::Undo { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Re-apply the most recently undone snapshot. `Ok(None)` means /// nothing to redo. pub async fn redo(&self) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::Redo { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Metadata of the snapshot `undo` would restore, without /// restoring it — for the confirmation prompt. pub async fn peek_undo(&self) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::PeekUndo { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Metadata of the snapshot `redo` would restore. pub async fn peek_redo(&self) -> Result, DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::PeekRedo { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } /// Open a batch so a multi-command operation (`replay`, future /// batch commands) records a single undo step (ADR-0006 /// Amendment 1). Pair with [`Database::end_batch`]. `source` is /// the batch command text recorded on the boundary snapshot. pub async fn begin_batch(&self, source: Option) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::BeginBatch { source, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone) } /// Close a batch opened with [`Database::begin_batch`]. The /// boundary snapshot is kept iff a mutation committed during the /// batch. pub async fn end_batch(&self) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::EndBatch { reply }).await?; recv.await.map_err(|_| DbError::WorkerGone) } async fn send(&self, req: Request) -> Result<(), DbError> { self.inbox.send(req).await.map_err(|_| DbError::WorkerGone) } } /// Internal table tracking the user-facing type each column was /// declared with. The structure view consults this so users see /// the names they typed (`serial`, `date`) rather than SQLite's /// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005 /// promise. Track 2's project file format is the long-term home /// for this metadata; this table is the in-database mirror that /// makes round-trip rendering work today. const META_TABLE: &str = "__rdbms_playground_columns"; const REL_TABLE: &str = "__rdbms_playground_relationships"; /// Single-row key/value table that carries project metadata /// the YAML serializer needs to round-trip — currently /// `created_at`. Created on first connect and only ever /// written by us; the user never touches it directly. const META_PROJECT_TABLE: &str = "__rdbms_playground_meta"; /// Table-level `CHECK ()` constraints (ADR-0035 §4a.3). The /// engine exposes no PRAGMA for CHECK constraints, so — unlike UNIQUE / /// PK / FK, which are read back from PRAGMA — a table-level CHECK has no /// engine-readable home and this table is its source of truth. One row /// per CHECK, ordered by `seq` (declaration order). const CHECK_TABLE: &str = "__rdbms_playground_table_checks"; fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch(&format!( "PRAGMA foreign_keys = ON;\n\ CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\ table_name TEXT NOT NULL,\n\ column_name TEXT NOT NULL,\n\ user_type TEXT NOT NULL,\n\ check_expr TEXT,\n\ PRIMARY KEY (table_name, column_name)\n\ ) STRICT;\n\ CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\ name TEXT NOT NULL UNIQUE,\n\ parent_table TEXT NOT NULL,\n\ parent_column TEXT NOT NULL,\n\ child_table TEXT NOT NULL,\n\ child_column TEXT NOT NULL,\n\ on_delete TEXT NOT NULL,\n\ on_update TEXT NOT NULL,\n\ PRIMARY KEY (child_table, child_column)\n\ ) STRICT;\n\ CREATE TABLE IF NOT EXISTS {CHECK_TABLE} (\n\ table_name TEXT NOT NULL,\n\ seq INTEGER NOT NULL,\n\ check_expr TEXT NOT NULL,\n\ name TEXT,\n\ PRIMARY KEY (table_name, seq)\n\ ) STRICT;\n\ CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\ key TEXT NOT NULL PRIMARY KEY,\n\ value TEXT NOT NULL\n\ ) STRICT;" ))?; // Seed `created_at` once. INSERT OR IGNORE keeps the value // stable across re-opens of the same database. conn.execute( &format!( "INSERT OR IGNORE INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1)" ), [iso8601_now()], )?; Ok(()) } /// Current UTC time as ISO-8601 with second precision. /// Duplicates the helper in `persistence::history` rather /// than depending on it, since this code path runs at /// connection setup before the rest of the worker is up. fn iso8601_now() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let day_secs = secs.rem_euclid(86_400); let h = day_secs / 3600; let m = (day_secs % 3600) / 60; let s = day_secs % 60; let days = secs.div_euclid(86_400); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m_cal = if mp < 10 { mp + 3 } else { mp - 9 }; let y_cal = if m_cal <= 2 { y + 1 } else { y }; format!("{y_cal:04}-{m_cal:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") } fn worker_loop( conn: Connection, persistence: Option, snapshots: Option, mut rx: mpsc::Receiver, ) { debug!("db worker started"); // `conn` must be mutable: restoring a snapshot (undo/redo) writes // into the live connection via the backup API (`&mut`). let mut conn = conn; let snap = snapshots.as_ref(); let mut batch = BatchState::default(); while let Some(req) = rx.blocking_recv() { // Undo/redo/peek/batch are handled here: undo/redo need // `&mut conn` for the restore, and batch state lives across // requests. Everything else goes to `handle_request`, which // brackets mutations with a pre-op snapshot. match req { Request::Undo { reply } => { let _ = reply.send(do_undo(snap, &mut conn)); } Request::Redo { reply } => { let _ = reply.send(do_redo(snap, &mut conn)); } Request::PeekUndo { reply } => { let _ = reply.send(peek_undo_op(snap)); } Request::PeekRedo { reply } => { let _ = reply.send(peek_redo_op(snap)); } Request::BeginBatch { source, reply } => { begin_batch(snap, &conn, &mut batch, source.as_deref()); let _ = reply.send(()); } Request::EndBatch { reply } => { end_batch(snap, &mut batch); let _ = reply.send(()); } other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other), } } debug!("db worker exiting"); } /// Worker-side undo bracketing state for the request stream. /// `active` is set between `BeginBatch`/`EndBatch` so per-command /// snapshots are suppressed in favour of one boundary snapshot for /// the whole batch (ADR-0006 Amendment 1). #[derive(Default)] struct BatchState { active: bool, dirty: bool, staged: Option, } fn snapshot_to_db_error(e: SnapshotError) -> DbError { DbError::Io(e.to_string()) } /// Stage a pre-mutation snapshot, never failing the user's command if /// the snapshot itself can't be taken (the real persistence is the /// durable state — the snapshot is a best-effort safety net). Returns /// `None` when undo is off, the command has no user `source` (an /// internal op, e.g. open-time rebuild — not undoable), or staging /// failed. fn stage_pre_mutation( snap: Option<&SnapshotStore>, conn: &Connection, source: Option<&str>, ) -> Option { let store = snap?; let src = source?; match store.stage(conn, src) { Ok(staged) => Some(staged), Err(e) => { warn!(error = %e, "could not stage undo snapshot; command proceeds without undo"); None } } } /// Run a mutating handler with undo bracketing: stage before, then /// finalise on success / discard on failure — or, inside a batch, /// just mark the batch dirty so its single boundary snapshot is kept. /// The command result is always sent; snapshot bookkeeping never /// fails the user's actual work. fn snapshot_then( snap: Option<&SnapshotStore>, batch: &mut BatchState, conn: &Connection, source: Option<&str>, reply: oneshot::Sender>, run: impl FnOnce() -> Result, ) { let staged = if batch.active { None } else { stage_pre_mutation(snap, conn, source) }; let result = run(); let committed = result.is_ok(); if batch.active { if committed { batch.dirty = true; } } else if let Some(store) = snap { let outcome = match staged { Some(st) if committed => store.finalize(st).map(|_| ()), Some(st) => store.discard(st), // No snapshot was staged. If this is a committed user // mutation (source present → staging FAILED, not just an // internal op), the redo stack is now stale and must be // cleared, or a later `redo` would silently discard this // work. `finalize` would have done this; it didn't run. None if committed && source.is_some() => store.clear_redo(), None => Ok(()), }; if let Err(e) = outcome { warn!(error = %e, "undo snapshot bookkeeping failed (command already applied)"); } } let _ = reply.send(result); } /// Open a batch: one boundary snapshot, then suppress per-command /// snapshots until `end_batch`. fn begin_batch( snap: Option<&SnapshotStore>, conn: &Connection, batch: &mut BatchState, source: Option<&str>, ) { if batch.active { warn!("BeginBatch while a batch is active; ignoring (no nested batches)"); return; } batch.staged = stage_pre_mutation(snap, conn, source); batch.active = true; batch.dirty = false; } /// Close a batch: keep the boundary snapshot iff a mutation committed /// during it, else discard it (an all-skips batch leaves no undo step). fn end_batch(snap: Option<&SnapshotStore>, batch: &mut BatchState) { if !batch.active { warn!("EndBatch with no active batch; ignoring"); return; } if let Some(store) = snap { let outcome = match batch.staged.take() { Some(st) if batch.dirty => store.finalize(st).map(|_| ()), Some(st) => store.discard(st), // Boundary snapshot failed to stage but mutations // committed: clear the now-stale redo stack (same // data-loss guard as the per-command path). None if batch.dirty => store.clear_redo(), None => Ok(()), }; if let Err(e) = outcome { warn!(error = %e, "batch undo snapshot bookkeeping failed"); } } batch.active = false; batch.dirty = false; } fn do_undo( snap: Option<&SnapshotStore>, conn: &mut Connection, ) -> Result, DbError> { snap.map_or(Ok(None), |store| { store.undo(conn).map_err(snapshot_to_db_error) }) } fn do_redo( snap: Option<&SnapshotStore>, conn: &mut Connection, ) -> Result, DbError> { snap.map_or(Ok(None), |store| { store.redo(conn).map_err(snapshot_to_db_error) }) } fn peek_undo_op(snap: Option<&SnapshotStore>) -> Result, DbError> { snap.map_or(Ok(None), |s| s.peek_undo().map_err(snapshot_to_db_error)) } fn peek_redo_op(snap: Option<&SnapshotStore>) -> Result, DbError> { snap.map_or(Ok(None), |s| s.peek_redo().map_err(snapshot_to_db_error)) } fn handle_request( conn: &Connection, persistence: Option<&Persistence>, snap: Option<&SnapshotStore>, batch: &mut BatchState, req: Request, ) { match req { Request::CreateTable { name, columns, primary_key, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table( conn, persistence, source.as_deref(), &name, &columns, &primary_key, &[], &[], &[], )); } Request::SqlCreateTable { name, columns, primary_key, unique_constraints, check_constraints, foreign_keys, if_not_exists, source, reply, } => { // `IF NOT EXISTS` on an existing table is a no-op: reply // `Skipped` with the existing structure and take **no** // snapshot (there is nothing to undo). The submitted line is // still journalled — like other read-only / no-op commands // (`show table`), it belongs in the complete journal // (ADR-0034). ADR-0035 §4. if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) { let result = do_describe_table(conn, &name).and_then(|desc| { if let (Some(p), Some(text)) = (persistence, source.as_deref()) { p.append_history(text).map_err(DbError::from_persistence)?; } Ok(CreateOutcome::Skipped(desc)) }); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_create_table( conn, persistence, source.as_deref(), &name, &columns, &primary_key, &unique_constraints, &check_constraints, &foreign_keys, ) .map(CreateOutcome::Created) }); } } Request::DropTable { name, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { 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, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column( conn, persistence, source.as_deref(), &table, &column, )); } Request::DropColumn { table, column, cascade, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column( conn, persistence, source.as_deref(), &table, &column, cascade, )); } Request::RenameColumn { table, old, new, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column( conn, persistence, source.as_deref(), &table, &old, &new, )); } Request::RenameTable { table, new, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table( conn, persistence, source.as_deref(), &table, &new, )); } Request::ChangeColumnType { table, column, ty, mode, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type( conn, persistence, source.as_deref(), &table, &column, ty, mode, )); } Request::ListTables { reply } => { let _ = reply.send(do_list_tables(conn)); } Request::DescribeTable { name, source, reply, } => { let _ = reply.send(do_describe_table_request( conn, persistence, source.as_deref(), &name, )); } Request::AddRelationship { name, parent_table, parent_column, child_table, child_column, on_delete, on_update, create_fk, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship( conn, persistence, source.as_deref(), name.as_deref(), &parent_table, &parent_column, &child_table, &child_column, on_delete, on_update, create_fk, )); } Request::DropRelationship { selector, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship( conn, persistence, source.as_deref(), &selector, )); } Request::AddIndex { name, table, columns, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index( conn, persistence, source.as_deref(), 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 { selector, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index( conn, persistence, source.as_deref(), &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, constraint, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint( conn, persistence, source.as_deref(), &table, &column, &constraint, )); } Request::DropConstraint { table, column, kind, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint( conn, persistence, source.as_deref(), &table, &column, kind, )); } Request::AlterAddTableCheck { table, name, expr_sql, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_alter_add_table_check( conn, persistence, source.as_deref(), &table, name.as_deref(), &expr_sql, ) }); } Request::AlterAddUnique { table, columns, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns) }); } Request::AlterDropConstraint { table, name, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name) }); } Request::AlterAddForeignKey { child_table, name, fk, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_alter_add_foreign_key( conn, persistence, source.as_deref(), &child_table, name.as_deref(), &fk, ) }); } Request::Insert { table, columns, values, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert( conn, persistence, source.as_deref(), &table, columns.as_deref(), &values, )); } Request::Update { table, assignments, filter, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update( conn, persistence, source.as_deref(), &table, &assignments, &filter, )); } Request::Delete { table, filter, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete( conn, persistence, source.as_deref(), &table, &filter, )); } Request::QueryData { table, filter, limit, source, reply, } => { let _ = reply.send(do_query_data_request( conn, persistence, source.as_deref(), &table, filter.as_ref(), limit, )); } Request::RunSelect { sql, source, reply } => { let _ = reply.send(do_run_select_request( conn, persistence, source.as_deref(), &sql, )); } Request::RunSqlInsert { sql, source, target_table, listed_columns, row_source, returning, literal_rows, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert( conn, persistence, source.as_deref(), &sql, &target_table, &listed_columns, &row_source, returning, &literal_rows, )); } Request::RunSqlUpdate { sql, source, target_table, returning, set_literals, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update( conn, persistence, source.as_deref(), &sql, &target_table, returning, &set_literals, )); } Request::RunSqlDelete { sql, source, target_table, returning, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete( conn, persistence, source.as_deref(), &sql, &target_table, returning, )); } Request::RebuildFromText { project_path, source, reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text( conn, persistence, source.as_deref(), &project_path, )); } Request::ExplainPlan { query, reply } => { let _ = reply.send(do_explain_plan(conn, &query)); } Request::ReadRelationships { table, reply } => { let result = do_read_relationships(conn, &table); let _ = reply.send(result); } Request::FindRowsMatching { table, column, value, limit, reply, } => { let result = do_find_rows_matching(conn, &table, &column, &value, limit); let _ = reply.send(result); } Request::ListNamesFor { source, reply } => { let result = do_list_names_for(conn, source); let _ = reply.send(result); } // Undo/redo/peek/batch are intercepted in `worker_loop` (they // need `&mut conn` or persistent batch state) and never reach // here. Listed explicitly so a new variant still forces a // decision at compile time. Request::Undo { .. } | Request::Redo { .. } | Request::PeekUndo { .. } | Request::PeekRedo { .. } | Request::BeginBatch { .. } | Request::EndBatch { .. } => { unreachable!("undo/redo/peek/batch are handled in worker_loop") } } } /// Schema-name lookup for the completion engine /// (ADR-0022 §9). Non-schema sources (`NewName`, `Types`, `Free`) /// never reach here — the public `list_names_for` short-circuits. fn do_list_names_for( conn: &Connection, source: crate::dsl::grammar::IdentSource, ) -> Result, DbError> { use crate::dsl::grammar::IdentSource; match source { IdentSource::Tables => do_list_tables(conn), IdentSource::Columns => { // Distinct column names across all user tables. // v1 simplification: no table-context binding // (ADR-0022 stage 6 note). let mut stmt = conn .prepare(&format!( "SELECT DISTINCT column_name \ FROM {META_TABLE} \ ORDER BY column_name;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } IdentSource::Relationships => { let mut stmt = conn .prepare(&format!( "SELECT name FROM {REL_TABLE} ORDER BY name;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } IdentSource::Indexes => { // User indexes only: a `CREATE INDEX` statement // leaves a non-null `sql`, whereas the automatic // indexes backing PKs / UNIQUE constraints have a // null `sql`. let mut stmt = conn .prepare( "SELECT name FROM sqlite_master \ WHERE type = 'index' AND sql IS NOT NULL \ ORDER BY name;", ) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } IdentSource::NewName | IdentSource::Types | IdentSource::Free => Ok(Vec::new()), } } /// Read both directions of FK relationships for `table`. Used /// by `Request::ReadRelationships` (ADR-0019 §6 enrichment). fn do_read_relationships(conn: &Connection, table: &str) -> RelationshipsReply { let outbound = read_relationships_outbound(conn, table)?; let inbound = read_relationships_inbound(conn, table)?; Ok((outbound, inbound)) } /// `SELECT * FROM
WHERE = LIMIT `. /// Used by the runtime to pinpoint rows after a UNIQUE / FK /// violation (ADR-0019 §6, ADR-0017 §7). Returns /// `DbError::Sqlite` on bind failure, missing column, etc. — /// callers treat any error as "no diagnostic table available" /// and fall back to the headline-only wording. fn do_find_rows_matching( conn: &Connection, table: &str, column: &str, value: &Value, limit: usize, ) -> Result { let schema = read_schema(conn, table)?; let col_info = schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {table}.{column}"), kind: SqliteErrorKind::NoSuchColumn, })?; let ty = col_info.user_type.ok_or_else(|| { DbError::Unsupported(format!( "column `{column}` has no user-type metadata; cannot pinpoint" )) })?; let bound = value .bind_for_column(column, ty) .map_err(|e| DbError::InvalidValue(e.to_string()))?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); let cols_csv = column_names .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let sql = format!( "SELECT {cols} FROM {tbl} WHERE {col} = ?1 LIMIT {n};", cols = cols_csv, tbl = quote_ident(table), col = quote_ident(column), n = limit, ); let bound_value = bound_to_sqlite_value(&bound); let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let rows_iter = stmt .query_map(rusqlite::params![bound_value], |row| { let mut cells: Vec = Vec::with_capacity(column_names.len()); for i in 0..column_names.len() { cells.push(row.get(i)?); } Ok(cells) }) .map_err(DbError::from_rusqlite)?; let mut rows: Vec>> = Vec::new(); for r in rows_iter { let cells = r.map_err(DbError::from_rusqlite)?; let formatted: Vec> = cells .into_iter() .zip(column_types.iter()) .map(|(v, ty)| format_cell(v, *ty)) .collect(); rows.push(formatted); } Ok(DataResult { table_name: table.to_string(), columns: column_names, column_types, rows, }) } /// Set of changes a mutation made, used by the post-mutation /// persistence phase to know which text targets need refreshing /// (ADR-0015 §6). #[derive(Debug, Default)] struct Changes { /// Schema (tables / columns / relationships) was modified — /// `project.yaml` needs to be rewritten. schema_dirty: bool, /// Tables whose row data changed and whose CSVs need to be /// re-emitted from the post-mutation state. rewritten_tables: Vec, /// Tables that were dropped — their CSVs should be removed. deleted_tables: Vec, } /// Drive the post-mutation persistence phase: write the YAML /// schema, rewrite affected CSVs, append `history.log`. Called /// before `tx.commit()` so a failure here causes the SQLite /// transaction to roll back automatically (the `Drop` impl on /// `Transaction` rolls back on drop). /// /// Read-only requests (no schema change, no row writes, no /// drops) still use this to append `history.log` if `source` /// is set; they pass an empty `Changes`. fn finalize_persistence( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, changes: &Changes, ) -> Result<(), DbError> { let Some(p) = persistence else { return Ok(()); }; if changes.schema_dirty { let schema = read_schema_snapshot(conn)?; p.write_schema(&schema).map_err(DbError::from_persistence)?; } for table in &changes.rewritten_tables { if let Some(snapshot) = read_table_snapshot(conn, table)? { p.write_table_data(&snapshot) .map_err(DbError::from_persistence)?; } } for table in &changes.deleted_tables { p.delete_table_data(table) .map_err(DbError::from_persistence)?; } if let Some(text) = source { p.append_history(text) .map_err(DbError::from_persistence)?; } Ok(()) } /// Read the full user-facing schema (tables, columns, /// relationships, project metadata) from the database. Reads /// committed *or* in-tx state because SQLite presents the /// same connection's writes back through the same connection. fn read_schema_snapshot(conn: &Connection) -> Result { let table_names = do_list_tables(conn)?; let mut tables: Vec = Vec::with_capacity(table_names.len()); for name in &table_names { let read = read_schema(conn, name)?; let columns: Vec = read .columns .iter() .map(|c| ColumnSchema { name: c.name.clone(), unique: c.unique, not_null: c.notnull, default: c.default_sql.clone(), check: c.check.clone(), // user_type is always populated for tables we // created; the fallback is defensive. user_type: c.user_type.unwrap_or(Type::Text), }) .collect(); tables.push(TableSchema { name: name.clone(), primary_key: read.primary_key.clone(), columns, unique_constraints: read.unique_constraints.clone(), check_constraints: read.check_constraints.clone(), }); } let relationships = read_all_relationships(conn)?; let mut indexes: Vec = Vec::new(); for name in &table_names { for idx in read_table_indexes(conn, name)? { indexes.push(IndexSchema { name: idx.name, table: name.clone(), columns: idx.columns, unique: idx.unique, }); } } let created_at = read_project_created_at(conn)?; Ok(SchemaSnapshot { created_at, tables, relationships, indexes, }) } fn read_all_relationships(conn: &Connection) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT name, parent_table, parent_column, child_table, child_column, \ on_delete, on_update \ FROM {REL_TABLE} \ ORDER BY rowid" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| { Ok(RelationshipSchema { name: row.get(0)?, parent_table: row.get(1)?, parent_column: row.get(2)?, child_table: row.get(3)?, child_column: row.get(4)?, on_delete: parse_action_from_sqlite(row.get::<_, String>(5)?.as_str()), on_update: parse_action_from_sqlite(row.get::<_, String>(6)?.as_str()), }) }) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } fn read_project_created_at(conn: &Connection) -> Result { let value: Option = conn .query_row( &format!("SELECT value FROM {META_PROJECT_TABLE} WHERE key = 'created_at'"), [], |row| row.get(0), ) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), }) .map_err(DbError::from_rusqlite)?; Ok(value.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())) } /// Read a single table's full row data, returning `None` if /// the table no longer exists (e.g. a recent `drop_table`). fn read_table_snapshot( conn: &Connection, table: &str, ) -> Result, DbError> { if !user_table_exists(conn, table)? { return Ok(None); } let read = read_schema(conn, table)?; let columns: Vec = read .columns .iter() .map(|c| ColumnSchema { name: c.name.clone(), user_type: c.user_type.unwrap_or(Type::Text), unique: c.unique, not_null: c.notnull, default: c.default_sql.clone(), check: c.check.clone(), }) .collect(); let column_idents: Vec = read .columns .iter() .map(|c| quote_ident(&c.name)) .collect(); let sql = format!( "SELECT {} FROM {} ORDER BY rowid", column_idents.join(", "), quote_ident(table), ); let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let column_count = read.columns.len(); let mut rows: Vec> = Vec::new(); let mut iter = stmt.query([]).map_err(DbError::from_rusqlite)?; while let Some(row) = iter.next().map_err(DbError::from_rusqlite)? { let mut record: Vec = Vec::with_capacity(column_count); for i in 0..column_count { record.push(row_value_to_cell(row, i)?); } rows.push(record); } Ok(Some(TableSnapshot { name: table.to_string(), columns, rows, })) } fn user_table_exists(conn: &Connection, table: &str) -> Result { let count: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_schema \ WHERE type = 'table' AND name = ?1 \ AND substr(name, 1, 8) != '__rdbms_'", [table], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; Ok(count > 0) } /// An engine-neutral "no such table" error for `name`. fn no_such_table(name: &str) -> DbError { DbError::Sqlite { message: format!("no such table: {name}"), kind: SqliteErrorKind::NoSuchTable, } } /// Resolve a user-supplied table name to its **stored (canonical) case**, /// or `None` if no such user table exists. /// /// SQL identifiers are case-insensitive, so a user may type a table name /// in any capitalization and the engine resolves it. But our metadata /// tables (keyed by `table_name` / `parent_table` / `child_table`) and the /// `data/
.csv` files are keyed by the *stored* case, and TEXT `=` /// is case-sensitive — so an executor that used the as-typed name would /// drift the metadata/CSV out of step with the live schema. Every executor /// that names a table canonicalizes first and then operates on the /// canonical name, keeping the live schema, the metadata, and the CSV in /// step regardless of how the user capitalised the name. /// /// Internal `__rdbms_*` tables are excluded (treated as non-existent), /// folding the [`reject_internal_table_name`] guard into the same lookup. fn canonical_table_name(conn: &Connection, name: &str) -> Result, DbError> { let mut stmt = conn .prepare( "SELECT name FROM sqlite_schema \ WHERE type = 'table' AND name = ?1 COLLATE NOCASE \ AND name NOT LIKE 'sqlite_%' \ AND substr(name, 1, 8) != '__rdbms_'", ) .map_err(DbError::from_rusqlite)?; let mut rows = stmt.query([name]).map_err(DbError::from_rusqlite)?; match rows.next().map_err(DbError::from_rusqlite)? { Some(row) => Ok(Some(row.get::<_, String>(0).map_err(DbError::from_rusqlite)?)), None => Ok(None), } } /// Resolve a table name to its canonical stored case, erroring with a /// "no such table" if it does not exist (the common executor entry guard). fn require_canonical_table(conn: &Connection, name: &str) -> Result { canonical_table_name(conn, name)?.ok_or_else(|| no_such_table(name)) } fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result { use rusqlite::types::ValueRef; let v = row.get_ref(idx).map_err(DbError::from_rusqlite)?; Ok(match v { ValueRef::Null => CellValue::Null, ValueRef::Integer(n) => CellValue::Integer(n), ValueRef::Real(f) => CellValue::Real(f), ValueRef::Text(bytes) => { CellValue::Text(String::from_utf8_lossy(bytes).into_owned()) } ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()), }) } /// Quote an identifier for safe inclusion in DDL. Doubles any /// embedded double-quotes per SQL convention. fn quote_ident(name: &str) -> String { let mut out = String::with_capacity(name.len() + 2); out.push('"'); for c in name.chars() { if c == '"' { out.push_str("\"\""); } else { out.push(c); } } out.push('"'); out } /// Render a column's constraint-DDL suffix (ADR-0029) — the /// ` NOT NULL` / ` UNIQUE` / ` DEFAULT ` fragment that /// follows the column's type in a `CREATE TABLE` or `ALTER /// TABLE ADD COLUMN`. The default literal is bound against the /// column's user-facing type, so `default 18` on an `int` /// column emits `DEFAULT 18` and `default 'x'` on a `text` /// column emits `DEFAULT 'x'`. (`CHECK` joins this in a later /// ADR-0029 step.) fn column_constraints_sql(spec: &ColumnSpec) -> Result { let mut sql = String::new(); if spec.not_null { sql.push_str(" NOT NULL"); } if spec.unique { sql.push_str(" UNIQUE"); } // Advanced-mode raw `DEFAULT ` (ADR-0035 §4a.2) takes // precedence over a simple-mode typed default; SQLite stores the // literal text and `PRAGMA table_info` reports it back for the // round-trip (no metadata needed for DEFAULT). if let Some(raw) = &spec.default_sql { sql.push_str(" DEFAULT "); sql.push_str(raw); } else if let Some(literal) = default_sql_literal(spec)? { sql.push_str(" DEFAULT "); sql.push_str(&literal); } Ok(sql) } /// The SQL literal for a column's `DEFAULT` value, bound /// against the column's user-facing type (ADR-0029). `None` /// when the column carries no default. fn default_sql_literal(spec: &ColumnSpec) -> Result, DbError> { match &spec.default { Some(value) => Ok(Some(value_to_default_sql(value, &spec.name, spec.ty)?)), None => Ok(None), } } /// The SQL literal for a `DEFAULT` `value` on a `column` of /// user-facing type `ty` (ADR-0029) — the value-bound, /// type-checked rendering shared by `default_sql_literal` (the /// create-table / add-column path) and `do_add_constraint` /// (the `add constraint default` path). fn value_to_default_sql(value: &Value, column: &str, ty: Type) -> Result { let bound = value .bind_for_column(column, ty) .map_err(|e| DbError::InvalidValue(e.to_string()))?; Ok(sql_literal(&bound_to_sqlite_value(&bound))) } /// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 / /// §7) — the form stored in the `check_expr` metadata column /// and emitted into column DDL. `compile_expr` produces /// `?N`-parameterised SQL; `inline_params_for_display` /// (ADR-0028) folds the literals back in, since DDL admits no /// parameters. fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String { let mut params: Vec = Vec::new(); let sql = compile_expr(expr, schema, &mut params); inline_params_for_display(&sql, ¶ms) } /// A minimal `ReadSchema` built from column specs — enough for /// `compile_expr` to resolve column types when compiling a /// `CHECK` at create-table time, before the table exists. fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema { ReadSchema { columns: columns .iter() .map(|c| ReadColumn { name: c.name.clone(), sqlite_type: c.ty.sqlite_strict_type().to_string(), notnull: c.not_null, primary_key: primary_key.contains(&c.name), unique: c.unique, default_sql: None, check: None, user_type: Some(c.ty), }) .collect(), primary_key: primary_key.to_vec(), foreign_keys: Vec::new(), unique_constraints: Vec::new(), check_constraints: Vec::new(), } } /// Insert a column's row into the metadata table — the user /// type, plus the compiled `CHECK` SQL when present /// (ADR-0029 §7). fn insert_column_metadata( conn: &Connection, table: &str, column: &str, user_type: Type, check_sql: Option<&str>, ) -> Result<(), DbError> { conn.execute( &format!( "INSERT INTO {META_TABLE} \ (table_name, column_name, user_type, check_expr) \ VALUES (?1, ?2, ?3, ?4);" ), rusqlite::params![table, column, user_type.keyword(), check_sql], ) .map_err(DbError::from_rusqlite)?; Ok(()) } /// The result of an advanced-mode SQL `CREATE TABLE` (ADR-0035 §4). /// /// Either the table was created, or `IF NOT EXISTS` matched an /// existing table and the statement was a no-op. Both carry the /// table's structure so the runtime can render it; `Skipped` also /// drives the "already exists — skipped" note. #[derive(Debug)] pub enum CreateOutcome { Created(TableDescription), Skipped(TableDescription), } /// The result of an advanced-mode SQL `DROP TABLE` (ADR-0035 §4, 4c). /// /// Either the table was dropped, or `IF EXISTS` matched no table and /// the statement was a no-op that drives the "doesn't exist — skipped" /// note. Carries no payload — the runtime renders the note from the /// command's table name. #[derive(Debug)] pub enum DropOutcome { Dropped, Skipped, } /// 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, persistence: Option<&Persistence>, source: Option<&str>, name: &str, columns: &[ColumnSpec], primary_key: &[String], unique_constraints: &[Vec], check_constraints: &[String], foreign_keys: &[SqlForeignKey], ) -> Result { if columns.is_empty() { // SQLite requires at least one column. The DSL grammar // already prevents this, but defending here too keeps // the executor honest if anyone synthesises a Command // directly (tests, future scripting). return Err(DbError::Unsupported( "tables need at least one column".to_string(), )); } // Resolve + validate any foreign keys before building the DDL, so // an invalid reference aborts before the table is created (ADR-0035 // §5, sub-phase 4b). Self-references validate against the columns // being defined; other parents must already exist. let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?; // Inline `PRIMARY KEY` on the column when the table has a single // primary-key column and it is the **first** column — the exact // rule [`schema_to_ddl`] uses on rebuild, so a table's DDL is // identical whether freshly created or reconstructed (no // round-trip drift). SQLite grants an inline single-column PK // rowid-alias semantics; a compound PK, or a single PK that is not // the first column, gets a table-level constraint. `serial` // autoincrement does **not** depend on this — the insert path // computes the next value itself (verified by the multi-column / // rebuild round-trip tests) — so the choice is purely about // matching the rebuild generator (ADR-0035 §6.4). let inline_pk_col: Option<&str> = (primary_key.len() == 1 && !columns.is_empty() && primary_key[0] == columns[0].name) .then(|| primary_key[0].as_str()); // Compile each column's CHECK once (ADR-0029 §4) — reused // by the DDL clause and the metadata insert below. The // minimal schema gives `compile_expr` the column types. let check_schema = read_schema_for_specs(columns, primary_key); // Advanced-mode raw `CHECK` text (ADR-0035 §4a.2) wins over a // compiled simple-mode `Expr`; both are stored verbatim in the // column metadata and echoed by `schema_to_ddl` on rebuild. let check_sqls: Vec> = columns .iter() .map(|c| { c.check_sql .clone() .or_else(|| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema))) }) .collect(); let mut column_clauses: Vec = Vec::with_capacity(columns.len()); for (col, check_sql) in columns.iter().zip(&check_sqls) { let mut clause = format!( "{ident} {sqlite_type}", ident = quote_ident(&col.name), sqlite_type = col.ty.sqlite_strict_type(), ); if inline_pk_col == Some(col.name.as_str()) { clause.push_str(" PRIMARY KEY"); } // ADR-0029 column constraints. A single-column PK is // already NOT NULL + UNIQUE; the simple-mode grammar // rejects redundant declarations (ADR-0029 §9) and the // SQL builder de-dups them (ADR-0035 §6.5), so an // inline-PK column never carries them here. clause.push_str(&column_constraints_sql(col)?); if let Some(cs) = check_sql { clause.push_str(&format!(" CHECK ({cs})")); } column_clauses.push(clause); } let mut ddl = format!( "CREATE TABLE {ident} ({columns}", ident = quote_ident(name), columns = column_clauses.join(", "), ); if inline_pk_col.is_none() && !primary_key.is_empty() { let pk_idents: Vec = primary_key.iter().map(|n| quote_ident(n)).collect(); ddl.push_str(", PRIMARY KEY ("); ddl.push_str(&pk_idents.join(", ")); ddl.push(')'); } // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2). // Single-column UNIQUE rides on the column's inline `UNIQUE`; these // are the multi-column table-level constraints. for cols in unique_constraints { let idents: Vec = cols.iter().map(|n| quote_ident(n)).collect(); ddl.push_str(", UNIQUE ("); ddl.push_str(&idents.join(", ")); ddl.push(')'); } // Table-level CHECK constraints (ADR-0035 §4a.3), emitted verbatim // from the captured raw SQL text. Must stay identical to the // `schema_to_ddl` rebuild path (the §6.1 two-generators rule). // The engine has no PRAGMA to report these back, so they are also // recorded in `CHECK_TABLE` (below) as their source of truth. for expr in check_constraints { ddl.push_str(", CHECK ("); ddl.push_str(expr); ddl.push(')'); } // Foreign keys (ADR-0035 §5, sub-phase 4b) — emitted identically to // `schema_to_ddl` (the §6.1 two-generators rule): always the // explicit resolved parent column + both actions, so the create DDL // and the rebuild DDL match byte-for-byte. for fk in &resolved_fks { ddl.push_str(&format!( ", FOREIGN KEY ({child}) REFERENCES {parent}({pcol}) ON DELETE {od} ON UPDATE {ou}", child = quote_ident(&fk.child_column), parent = quote_ident(&fk.parent_table), pcol = quote_ident(&fk.parent_column), od = fk.on_delete.sql_clause(), ou = fk.on_update.sql_clause(), )); } ddl.push_str(") STRICT;"); debug!(ddl = %ddl, "create_table"); // Wrap the table-creation DDL and the metadata inserts in a // single transaction so they commit atomically — if either // step fails, neither side persists. let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; for (col, check_sql) in columns.iter().zip(&check_sqls) { insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?; } // Record table-level CHECKs in their metadata table (the engine // reports no CHECK constraints, ADR-0035 §4a.3). `seq` preserves // declaration order so read-back / rebuild re-emit them in order. for (seq, expr) in check_constraints.iter().enumerate() { tx.execute( &format!( "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr) VALUES (?1, ?2, ?3);" ), rusqlite::params![name, seq as i64, expr], ) .map_err(DbError::from_rusqlite)?; } // Foreign-key relationships (ADR-0035 §5): one metadata row per FK, // in the same transaction as the table — so the whole statement is // one undo step. for fk in &resolved_fks { insert_relationship_metadata( &tx, &fk.name, &fk.parent_table, &fk.parent_column, name, &fk.child_column, fk.on_delete, fk.on_update, )?; } let description = do_describe_table(conn, name)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![name.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } fn do_drop_table( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, name: &str, ) -> Result<(), DbError> { // Canonicalize the user-typed name to its stored case (and refuse a // non-existent / internal table), so the metadata DELETEs and the CSV // removal target the right name regardless of capitalization. let canonical_name = require_canonical_table(conn, name)?; let name = canonical_name.as_str(); // Refuse the drop while any *other* table still has a // relationship pointing at this one — dropping the parent // would leave dangling FK constraints in the children. The // user is told which relationships to drop first. let inbound = read_relationships_inbound(conn, name)?; if !inbound.is_empty() { let names: Vec<&str> = inbound.iter().map(|r| r.name.as_str()).collect(); return Err(DbError::Unsupported(format!( "cannot drop `{name}`: it is referenced by relationship(s) [{}]. \ Drop those relationships first.", names.join(", ") ))); } let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name)); debug!(ddl = %ddl, "drop_table"); let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; tx.execute( &format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"), [name], ) .map_err(DbError::from_rusqlite)?; // Outbound relationships are gone with the table — clean // their metadata too. tx.execute( &format!("DELETE FROM {REL_TABLE} WHERE child_table = ?1;"), [name], ) .map_err(DbError::from_rusqlite)?; // Table-level CHECK metadata goes with the table (ADR-0035 §4a.3). tx.execute( &format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1;"), [name], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: Vec::new(), deleted_tables: vec![name.to_string()], }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) } /// Add a column to an existing table. /// /// Two execution paths per ADR-0018 §6: /// /// - **Plain types** (`text`, `int`, `real`, `decimal`, `bool`, /// `date`, `datetime`): `ALTER TABLE ADD COLUMN`. Existing /// rows get NULL in the new column — that's the SQL standard /// behaviour and acceptable for non-auto-generated types. /// - **Auto-generated types** (`serial`, `shortid`): route /// through the rebuild-table primitive so we can populate /// the new column atomically with table creation. Existing /// rows receive a generated value (1..N for serial, fresh /// shortids for shortid) and the column gains UNIQUE + /// NOT NULL. /// /// `blob` is statically refused as a column-add target by the /// existing infrastructure (no DSL literal, downstream INSERT /// would fail), but the path itself is allowed via the plain /// branch — same as today. fn do_add_column( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, column: &ColumnSpec, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); if matches!(column.ty, Type::Serial | Type::ShortId) { // ADR-0029 §6: a `serial` / `shortid` column auto-fills // its own values, so a separate `default` is ambiguous. // (`default_sql` is the advanced-mode raw form — ADR-0035 §4e.) if column.default.is_some() || column.default_sql.is_some() { return Err(DbError::Unsupported(format!( "`{name}` is a {ty} column — it auto-fills its own values, \ so it cannot also carry a `default`.", name = column.name, ty = column.ty.keyword(), ))); } // A CHECK on an auto-generated column is supported at // `create table` time; adding one to a `serial` / // `shortid` column afterwards is not (the auto-fill // rebuild path does not thread it). `check_sql` is the // advanced-mode raw form. if column.check.is_some() || column.check_sql.is_some() { return Err(DbError::Unsupported(format!( "a `check` constraint on the auto-generated column `{}` \ can only be set when the table is created.", column.name, ))); } return do_add_auto_generated_column(conn, persistence, source, table, column); } // SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE` // or `CHECK`, and a `NOT NULL` column added that way must // carry a default — all route through the rebuild primitive // instead (ADR-0029 §6). The advanced-mode raw forms // (`check_sql` / `default_sql`, ADR-0035 §4e) count alongside // the typed AST forms. if column.unique || column.check.is_some() || column.check_sql.is_some() || (column.not_null && column.default.is_none() && column.default_sql.is_none()) { do_add_constrained_column_via_rebuild(conn, persistence, source, table, column) } else { do_add_plain_column(conn, persistence, source, table, column) } } /// Plain ALTER-TABLE path for non-auto-generated types. fn do_add_plain_column( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { // The plain `ALTER TABLE ADD COLUMN` path. `do_add_column` // only routes here when the constraints are ALTER-expressible // (no UNIQUE; NOT NULL only alongside a default), so the // ADR-0029 suffix appends cleanly. let ty = spec.ty; let column = spec.name.as_str(); let ddl = format!( "ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{constraints};", tbl = quote_ident(table), col = quote_ident(column), sqlite_type = ty.sqlite_strict_type(), constraints = column_constraints_sql(spec)?, ); debug!(ddl = %ddl, "add_column"); let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; tx.execute( &format!( "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ VALUES (?1, ?2, ?3);" ), [table, column, ty.keyword()], ) .map_err(DbError::from_rusqlite)?; let description = do_describe_table(conn, table)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(AddColumnResult { description, client_side_notes: Vec::new(), }) } /// Auto-fill path for `serial` / `shortid` (ADR-0018 §6 + §9). /// /// Empty table: the new column is added through the rebuild /// primitive too, but no auto-fill / `[client-side]` note is /// produced — there are no rows to populate. fn do_add_auto_generated_column( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { use rusqlite::types::Value as RV; let ty = spec.ty; let column = spec.name.as_str(); let old_schema = read_schema(conn, table)?; if old_schema.columns.iter().any(|c| c.name == column) { return Err(DbError::Unsupported(format!( "column `{table}.{column}` already exists." ))); } let row_count = count_rows(conn, table)? as usize; // Build the new schema with the auto-generated column // appended. UNIQUE + NOT NULL emit per ADR-0018 §4. let mut new_schema = old_schema.clone(); new_schema.columns.push(ReadColumn { name: column.to_string(), sqlite_type: ty.sqlite_strict_type().to_string(), notnull: true, primary_key: false, unique: true, default_sql: None, check: None, user_type: Some(ty), }); // Generate auto-fill values for every existing row. let auto_fill_values: Vec = match ty { Type::Serial => (1..=row_count as i64).map(RV::Integer).collect(), Type::ShortId => generate_shortid_batch(row_count, &[])?, _ => unreachable!("guarded by `auto_generated` flag"), }; let old_columns: Vec = old_schema.columns.iter().map(|c| c.name.clone()).collect(); let new_columns: Vec = new_schema.columns.iter().map(|c| c.name.clone()).collect(); let copy_data = |tx: &rusqlite::Transaction<'_>, temp_name: &str, orig: &str| -> Result<(), DbError> { if row_count == 0 { return Ok(()); } // Read all rows from old, append the auto-fill value, // INSERT into temp. let select_cols = old_columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let select_sql = format!( "SELECT {select_cols} FROM {orig};", orig = quote_ident(orig), ); let cols_csv = new_columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let placeholders = (1..=new_columns.len()) .map(|i| format!("?{i}")) .collect::>() .join(", "); let insert_sql = format!( "INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});", temp = quote_ident(temp_name), ); let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?; let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?; let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; let mut row_idx = 0usize; while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? { let mut values: Vec = Vec::with_capacity(new_columns.len()); for i in 0..old_columns.len() { values.push(r.get(i).map_err(DbError::from_rusqlite)?); } values.push(auto_fill_values[row_idx].clone()); let params: Vec<&dyn rusqlite::ToSql> = values.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); insert_stmt .execute(rusqlite::params_from_iter(params)) .map_err(DbError::from_rusqlite)?; row_idx += 1; } Ok(()) }; let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { tx.execute( &format!( "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ VALUES (?1, ?2, ?3);" ), [table, column, ty.keyword()], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) }; rebuild_table_with_copy(conn, table, &new_schema, copy_data, metadata_updates)?; let description = do_describe_table(conn, table)?; let mut client_side_notes = Vec::new(); if row_count > 0 { client_side_notes.push(format_auto_fill_add_note(ty, row_count)); } Ok(AddColumnResult { description, client_side_notes, }) } /// Add a plain column whose constraints `ALTER TABLE ADD /// COLUMN` cannot express — `UNIQUE`, or `NOT NULL` with no /// default — through the rebuild primitive (ADR-0029 §6). A /// pre-flight check refuses, with a friendly message, the /// cases the table's existing rows would violate. fn do_add_constrained_column_via_rebuild( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { let old_schema = read_schema(conn, table)?; if old_schema.columns.iter().any(|c| c.name == spec.name) { return Err(DbError::Unsupported(format!( "column `{table}.{}` already exists.", spec.name, ))); } let row_count = count_rows(conn, table)?; // ADR-0029 §6 pre-flight refusals — caught before any SQL // write, surfaced as friendly messages. // A default may come from the typed AST (`default`, simple mode) or // the raw advanced-mode text (`default_sql`, ADR-0035 §4e); either // satisfies the NOT-NULL backfill and triggers the UNIQUE collision // guard. let has_default = spec.default.is_some() || spec.default_sql.is_some(); if spec.not_null && !has_default && row_count > 0 { return Err(DbError::Unsupported(format!( "adding the NOT NULL column `{}` to `{table}`, which already \ has {row_count} row(s), needs a `default` — every existing \ row would otherwise be null.", spec.name, ))); } if spec.unique && has_default && row_count > 1 { return Err(DbError::Unsupported(format!( "adding the UNIQUE column `{}` with a default to `{table}` \ would give all {row_count} existing rows the same value, \ breaking uniqueness.", spec.name, ))); } // The DEFAULT literal: the raw advanced-mode text wins over the typed // form (ADR-0035 §4e, mirroring `column_constraints_sql`). let default_sql = match &spec.default_sql { Some(raw) => Some(raw.clone()), None => default_sql_literal(spec)?, }; // Append the new column to the schema; the rebuild's // column-by-name copy leaves it at its DEFAULT (or NULL). let mut new_schema = old_schema.clone(); new_schema.columns.push(ReadColumn { name: spec.name.clone(), sqlite_type: spec.ty.sqlite_strict_type().to_string(), notnull: spec.not_null, primary_key: false, unique: spec.unique, default_sql, check: None, user_type: Some(spec.ty), }); // The CHECK: the raw advanced-mode text (`check_sql`) wins; otherwise // the typed `check` AST is compiled against the post-add schema (so // it may reference the new column itself). let check_sql = spec .check_sql .clone() .or_else(|| spec.check.as_ref().map(|e| compile_check_sql(e, &new_schema))); if let Some(last) = new_schema.columns.last_mut() { last.check.clone_from(&check_sql); } let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) }; rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; Ok(AddColumnResult { description: do_describe_table(conn, table)?, client_side_notes: Vec::new(), }) } // ================================================================= // add constraint / drop constraint (ADR-0029 §2.2 / §5 / §9) // ================================================================= /// Add a column-level constraint to an existing column /// (ADR-0029 §2.2). Steps: resolve the column; apply the §9 /// redundant-on-PK and §6 default-on-auto-generated refusals; /// run the §5 dry-run for the constraints existing data can /// violate (`not null` / `unique` / `check`); then apply the /// constraint through the rebuild-table primitive — SQLite's /// `ALTER TABLE` cannot add these to an existing column. fn do_add_constraint( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, column: &str, constraint: &Constraint, ) -> Result { // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // schema-mutation executors. Closes the simple `add constraint` // exposure and the SQL `ALTER TABLE … ADD CONSTRAINT` decomposition // target (ADR-0035 §4g). let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; let (col_is_pk, col_user_type) = { let col = old_schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {table}.{column}"), kind: SqliteErrorKind::NoSuchColumn, })?; (col.primary_key, col.user_type) }; let single_column_pk = old_schema.primary_key.len() == 1; // ADR-0029 §9 — a constraint the primary key already // implies is a friendly refusal, not a silent no-op. match constraint { Constraint::NotNull if col_is_pk => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a primary-key column, so it is already \ NOT NULL — there is no constraint to add." ))); } Constraint::Unique if col_is_pk && single_column_pk => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a single-column primary key, so it is \ already UNIQUE — there is no constraint to add." ))); } // ADR-0029 §6 — an auto-generated column fills its own // values, so a `default` would be a second, ambiguous // source of "the value when none is given". Constraint::Default(_) if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a {ty} column — it auto-fills its own \ values, so it cannot also carry a `default`.", ty = col_user_type.expect("matched Some above").keyword(), ))); } _ => {} } // Compile the CHECK once — reused by the dry-run predicate // and the column DDL / metadata write. let check_sql: Option = match constraint { Constraint::Check(expr) => Some(compile_check_sql(expr, &old_schema)), _ => None, }; // ADR-0029 §5 — refuse, before any SQL write, when the // existing rows violate the constraint. `default` never // touches existing rows, so it skips the dry-run. let refusal = match constraint { Constraint::NotNull => dry_run_not_null(conn, &old_schema, table, column)?, Constraint::Unique => dry_run_unique(conn, &old_schema, table, column)?, Constraint::Check(_) => dry_run_check( conn, &old_schema, table, column, check_sql.as_deref().expect("check_sql set for a Check constraint"), )?, Constraint::Default(_) => None, }; if let Some(message) = refusal { return Err(DbError::Unsupported(message)); } // Build the post-add schema: the target column gains the // constraint; every other column carries over unchanged. let mut new_schema = old_schema.clone(); { let target = new_schema .columns .iter_mut() .find(|c| c.name == column) .expect("column existence checked above"); match constraint { Constraint::NotNull => target.notnull = true, Constraint::Unique => target.unique = true, Constraint::Default(value) => { let ty = target.user_type.ok_or_else(|| { DbError::Unsupported(format!( "`{table}.{column}` has no user-type metadata; \ cannot bind a default value." )) })?; target.default_sql = Some(value_to_default_sql(value, column, ty)?); } Constraint::Check(_) => target.check.clone_from(&check_sql), } } let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { // Only CHECK needs a metadata write — NOT NULL / UNIQUE / // DEFAULT are recoverable from the engine's own catalog // (ADR-0029 §7). if matches!(constraint, Constraint::Check(_)) { tx.execute( &format!( "UPDATE {META_TABLE} SET check_expr = ?1 \ WHERE table_name = ?2 AND column_name = ?3;" ), rusqlite::params![check_sql, table, column], ) .map_err(DbError::from_rusqlite)?; } let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) }; rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; do_describe_table(conn, table) } /// Remove a column-level constraint from an existing column /// (ADR-0029 §2.2). Removing a constraint cannot violate the /// data, so there is no dry-run; the §9 PK-implied refusals /// still apply, and dropping a constraint the column does not /// carry is itself a friendly refusal. fn do_drop_constraint( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, column: &str, kind: ConstraintKind, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; let (col_is_pk, present) = { let col = old_schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {table}.{column}"), kind: SqliteErrorKind::NoSuchColumn, })?; let present = match kind { ConstraintKind::NotNull => col.notnull, ConstraintKind::Unique => col.unique, ConstraintKind::Default => col.default_sql.is_some(), ConstraintKind::Check => col.check.is_some(), }; (col.primary_key, present) }; let single_column_pk = old_schema.primary_key.len() == 1; // ADR-0029 §9 — the primary key still enforces these, so // there is nothing for `drop constraint` to remove. match kind { ConstraintKind::NotNull if col_is_pk => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a primary-key column — its NOT NULL is \ enforced by the primary key and cannot be dropped." ))); } ConstraintKind::Unique if col_is_pk && single_column_pk => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a single-column primary key — its \ UNIQUE is enforced by the primary key and cannot be dropped." ))); } _ => {} } if !present { return Err(DbError::Unsupported(format!( "`{table}.{column}` has no {kind} constraint to drop.", kind = kind.label(), ))); } let mut new_schema = old_schema.clone(); { let target = new_schema .columns .iter_mut() .find(|c| c.name == column) .expect("column existence checked above"); match kind { ConstraintKind::NotNull => target.notnull = false, ConstraintKind::Unique => target.unique = false, ConstraintKind::Default => target.default_sql = None, ConstraintKind::Check => target.check = None, } } let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { if kind == ConstraintKind::Check { tx.execute( &format!( "UPDATE {META_TABLE} SET check_expr = NULL \ WHERE table_name = ?1 AND column_name = ?2;" ), rusqlite::params![table, column], ) .map_err(DbError::from_rusqlite)?; } let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) }; rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?; do_describe_table(conn, table) } /// A row's primary-key cell values paired with its value in /// the column under test — the unit an ADR-0029 §5 dry-run /// scans. type DryRunRow = (Vec, rusqlite::types::Value); /// Read the primary-key cell values of every row of `table` /// matching `where_sql`, paired with the row's value in /// `column`. The shared read behind the three ADR-0029 §5 /// dry-runs; `where_sql` is built from `quote_ident`-quoted /// names and `compile_check_sql` output, never raw user text. fn read_constraint_dry_run_rows( conn: &Connection, schema: &ReadSchema, table: &str, column: &str, where_sql: &str, ) -> Result, DbError> { use rusqlite::types::Value as RV; let pk_columns = &schema.primary_key; let mut select_idents: Vec = pk_columns.iter().map(|c| quote_ident(c)).collect(); select_idents.push(quote_ident(column)); let sql = format!( "SELECT {cols} FROM {tbl} WHERE {pred};", cols = select_idents.join(", "), tbl = quote_ident(table), pred = where_sql, ); let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let pk_count = pk_columns.len(); let mut out = Vec::new(); let mut rows = stmt.query([]).map_err(DbError::from_rusqlite)?; while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? { let mut pk_values: Vec = Vec::with_capacity(pk_count); for i in 0..pk_count { pk_values.push(r.get(i).map_err(DbError::from_rusqlite)?); } let target: RV = r.get(pk_count).map_err(DbError::from_rusqlite)?; out.push((pk_values, target)); } Ok(out) } /// Header cells identifying an offending row in a §5 dry-run /// table — the primary-key columns. The DSL always creates a /// primary key, so the no-PK fallback (the constrained column /// itself) is defensive only. fn dry_run_id_headers(schema: &ReadSchema, column: &str) -> Vec { if schema.primary_key.is_empty() { vec![column.to_string()] } else { pk_header_cells(&schema.primary_key) } } fn dry_run_id_alignments(schema: &ReadSchema) -> Vec { if schema.primary_key.is_empty() { vec![Alignment::Left] } else { pk_header_alignments(&schema.primary_key, schema) } } fn dry_run_id_cells( schema: &ReadSchema, pk_values: &[rusqlite::types::Value], target: &rusqlite::types::Value, ) -> Vec { if schema.primary_key.is_empty() { vec![render_value(target)] } else { pk_value_cells(pk_values) } } /// Assemble a §5 dry-run refusal: a summary line above the /// pretty-printed table of offending rows. fn render_constraint_dry_run( summary: String, headers: &[String], alignments: &[Alignment], rows: Vec>, ) -> String { let mut out = format!("{summary}\n\n"); for line in render_diagnostic_table(headers, &rows, alignments) { out.push_str(&line); out.push('\n'); } out } /// ADR-0029 §5 dry-run for `add constraint not null`: any row /// whose target column holds `NULL` violates the constraint. fn dry_run_not_null( conn: &Connection, schema: &ReadSchema, table: &str, column: &str, ) -> Result, DbError> { let rows = read_constraint_dry_run_rows( conn, schema, table, column, &format!("{col} IS NULL", col = quote_ident(column)), )?; if rows.is_empty() { return Ok(None); } let total = rows.len(); let headers = dry_run_id_headers(schema, column); let alignments = dry_run_id_alignments(schema); let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut out_rows: Vec> = Vec::with_capacity(visible + 1); for (pk, target) in rows.iter().take(visible) { out_rows.push(dry_run_id_cells(schema, pk, target)); } if total > visible { out_rows.push(more_row(headers.len(), total - visible)); } Ok(Some(render_constraint_dry_run( crate::t!( "db.diagnostic.add_not_null_summary", table = table, column = column, total = total, ), &headers, &alignments, out_rows, ))) } /// ADR-0029 §5 dry-run for `add constraint unique`: any /// non-`NULL` value shared by two or more rows collides /// (SQL's "NULLs are distinct" rule means `NULL`s never do). fn dry_run_unique( conn: &Connection, schema: &ReadSchema, table: &str, column: &str, ) -> Result, DbError> { use std::collections::BTreeMap; let rows = read_constraint_dry_run_rows( conn, schema, table, column, &format!("{col} IS NOT NULL", col = quote_ident(column)), )?; let mut groups: BTreeMap> = BTreeMap::new(); for (pk, target) in rows { groups .entry(render_value(&target)) .or_default() .push((pk, target)); } let collisions: Vec<(String, Vec)> = groups .into_iter() .filter(|(_, members)| members.len() >= 2) .collect(); if collisions.is_empty() { return Ok(None); } let total = collisions.len(); let pk_label = if schema.primary_key.is_empty() { column.to_string() } else { schema.primary_key.join(", ") }; let headers = vec![ crate::t!("db.diagnostic.header_value"), crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label), ]; let alignments = vec![Alignment::Left, Alignment::Left]; let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut out_rows: Vec> = Vec::with_capacity(visible + 1); for (value, members) in collisions.iter().take(visible) { let ids = members .iter() .take(5) .map(|(pk, target)| { if schema.primary_key.is_empty() { render_value(target) } else { pk_value_cells_inline(pk) } }) .collect::>() .join(", "); let ids = if members.len() > 5 { format!("{ids}, …") } else { ids }; out_rows.push(vec![value.clone(), ids]); } if total > visible { out_rows.push(more_row(headers.len(), total - visible)); } Ok(Some(render_constraint_dry_run( crate::t!( "db.diagnostic.add_unique_summary", table = table, column = column, total = total, ), &headers, &alignments, out_rows, ))) } /// ADR-0029 §5 dry-run for `add constraint check`: any row for /// which the compiled `check_sql` is definitively false /// violates the constraint. (`NOT (expr)` is true only when /// `expr` is false — a `NULL` result, which the engine's CHECK /// also tolerates, does not select the row.) fn dry_run_check( conn: &Connection, schema: &ReadSchema, table: &str, column: &str, check_sql: &str, ) -> Result, DbError> { let rows = read_constraint_dry_run_rows( conn, schema, table, column, &format!("NOT ({check_sql})"), )?; if rows.is_empty() { return Ok(None); } let total = rows.len(); let mut headers = dry_run_id_headers(schema, column); headers.push(crate::t!("db.diagnostic.header_value")); let mut alignments = dry_run_id_alignments(schema); alignments.push(Alignment::Left); let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut out_rows: Vec> = Vec::with_capacity(visible + 1); for (pk, target) in rows.iter().take(visible) { let mut cells = dry_run_id_cells(schema, pk, target); cells.push(render_value(target)); out_rows.push(cells); } if total > visible { out_rows.push(more_row(headers.len(), total - visible)); } Ok(Some(render_constraint_dry_run( crate::t!( "db.diagnostic.add_check_summary", table = table, column = column, total = total, rule = check_sql, ), &headers, &alignments, out_rows, ))) } /// Generate `count` shortid values that don't collide with each /// other or with `existing` (a slice of currently-stored /// shortid values, used during change-column-to-shortid). Up to /// 5 retries per cell per ADR-0018 §3. fn generate_shortid_batch( count: usize, existing: &[String], ) -> Result, DbError> { use std::collections::HashSet; let mut taken: HashSet = existing.iter().cloned().collect(); let mut out: Vec = Vec::with_capacity(count); for _ in 0..count { let mut generated: Option = None; for _ in 0..5 { let candidate = shortid::generate(); if !taken.contains(&candidate) { taken.insert(candidate.clone()); generated = Some(candidate); break; } } match generated { Some(v) => out.push(rusqlite::types::Value::Text(v)), None => { return Err(DbError::Unsupported( "could not generate a unique shortid after 5 attempts; \ this typically indicates a generator-state issue, not \ a recoverable user error." .to_string(), )); } } } Ok(out) } /// `[client-side]` note line for `add column T: x (serial|shortid)` /// on a non-empty table per ADR-0018 §9. fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String { match ty { Type::Serial => { crate::t!("client_side.auto_fill_add_serial", count = row_count) } Type::ShortId => { crate::t!("client_side.auto_fill_add_shortid", count = row_count) } _ => unreachable!("called only for serial/shortid"), } } /// Drop a column from a table. /// /// Uses SQLite's native `ALTER TABLE … DROP COLUMN` /// (available since SQLite 3.35) so we get the engine's /// constraint checks for free; SQLite refuses if the column /// is part of the PK, has a UNIQUE constraint, is referenced /// in a CHECK, or is used in an FK. In addition we run two /// up-front checks so the user gets friendly messages /// before SQLite refuses: /// /// - Refuse PK columns (the dominant case the user might /// try). /// - Refuse columns involved in a declared relationship /// (per `__rdbms_playground_relationships`). Drop the /// relationship first. fn do_drop_column( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, column: &str, cascade: bool, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; let col_info = schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {table}.{column}"), kind: SqliteErrorKind::NoSuchColumn, })?; if col_info.primary_key { return Err(DbError::Unsupported(format!( "cannot drop primary-key column `{table}.{column}`. \ Drop the table or change the primary key first." ))); } let rel_count: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM {REL_TABLE} \ WHERE (parent_table = ?1 AND parent_column = ?2) \ OR (child_table = ?1 AND child_column = ?2);" ), rusqlite::params![table, column], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if rel_count > 0 { return Err(DbError::Unsupported(format!( "cannot drop `{table}.{column}` while a relationship \ references it; drop the relationship first." ))); } // Indexes covering this column (ADR-0025). Without // `--cascade` a covered column is refused; with it, the // covering indexes are dropped alongside the column. let covering: Vec = read_table_indexes(conn, table)? .into_iter() .filter(|i| i.columns.iter().any(|c| c == column)) .collect(); if !covering.is_empty() && !cascade { let names = covering .iter() .map(|i| format!("`{}`", i.name)) .collect::>() .join(", "); return Err(DbError::Unsupported(format!( "cannot drop `{table}.{column}` while an index covers \ it ({names}); drop the index first, or pass `--cascade` \ to drop the covering indexes too." ))); } // A composite UNIQUE covering this column (ADR-0035 Amendment 1): the // engine refuses to drop a column a UNIQUE constraint spans, so refuse // up-front with the constraint's derived name and the actionable drop // command. Single-column UNIQUEs ride on the column `unique` flag (the // engine drops their auto-index with the column), not // `unique_constraints`, so they do not reach here. if let Some(cols) = schema .unique_constraints .iter() .find(|cols| cols.iter().any(|c| c == column)) { let cname = unique_constraint_name(cols); return Err(DbError::Unsupported(format!( "cannot drop `{table}.{column}` — it is part of the UNIQUE \ constraint `{cname}` ({}); drop that constraint first \ (`alter table {table} drop constraint {cname}`).", cols.join(", "), ))); } // A single-column UNIQUE on this column (ADR-0029): the engine refuses // to drop a column carrying a UNIQUE constraint. Unlike a composite // UNIQUE (handled above), a single-column UNIQUE is removed by the // column-level `drop constraint` — point there (ADR-0035 Amendment 1, // gap B). if col_info.unique { return Err(DbError::Unsupported(format!( "cannot drop `{table}.{column}` — it has a UNIQUE constraint; \ remove the constraint first (`drop constraint unique from \ {table}.{column}`), then drop the column." ))); } // A CHECK (table-level, or a *different* column's column-level CHECK) // that references this column (ADR-0035 §4e, the 4a.3 deferral): a // deliberate up-front refusal — dropping the column would break that // CHECK and the rebuilt DDL would name a missing column. The column's // own column-level CHECK drops with it, so it does not block. // Friendly wording is H1. Guards both surfaces. if column_referenced_by_check(conn, table, &schema, column, false)? { return Err(DbError::Unsupported(format!( "cannot drop `{table}.{column}` — a CHECK constraint refers to \ it, and dropping the column would leave that rule pointing at \ a column that no longer exists. Drop or change the CHECK \ constraint first, then drop the column." ))); } let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; // Drop covering indexes first — the engine refuses // DROP COLUMN on an indexed column otherwise. `covering` // is empty unless `--cascade` was given (the refusal above). for index in &covering { tx.execute_batch(&format!( "DROP INDEX {ident};", ident = quote_ident(&index.name) )) .map_err(DbError::from_rusqlite)?; } let ddl = format!( "ALTER TABLE {tbl} DROP COLUMN {col};", tbl = quote_ident(table), col = quote_ident(column), ); debug!(ddl = %ddl, "drop_column"); tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; tx.execute( &format!( "DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;" ), [table, column], ) .map_err(DbError::from_rusqlite)?; let description = do_describe_table(conn, table)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DropColumnResult { description, dropped_indexes: covering.into_iter().map(|i| i.name).collect(), }) } /// Rename a column. /// /// Uses SQLite's native `ALTER TABLE … RENAME COLUMN` /// (available since SQLite 3.25), which automatically /// updates references in views, triggers, and FK /// declarations on other tables. We mirror the rename into /// our two metadata tables so they don't drift. fn do_rename_column( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, old: &str, new: &str, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; if !schema.columns.iter().any(|c| c.name == old) { return Err(DbError::Sqlite { message: format!("no such column: {table}.{old}"), kind: SqliteErrorKind::NoSuchColumn, }); } // A CHECK that references this column (ADR-0035 §4e): refuse — a // native RENAME COLUMN rewrites the live CHECK but the stored CHECK // text (table-level in `__rdbms_playground_table_checks`, or // column-level in `__rdbms_playground_columns`) keeps the old name, // drifting the metadata and breaking a later rebuild. A column's // *own* column-level CHECK drifts too (`include_self = true`). // Deliberate refusal (friendly wording is H1); guards both surfaces. if column_referenced_by_check(conn, table, &schema, old, true)? { return Err(DbError::Unsupported(format!( "cannot rename `{table}.{old}` — a CHECK constraint refers to \ it by name, and the rename would leave that rule pointing at \ the old name. Drop or change the CHECK constraint first, then \ rename the column." ))); } if old == new { // Nothing to do; refusing keeps behaviour // predictable rather than appearing to "succeed" // with no effect. return Err(DbError::Unsupported(format!( "rename: new name is identical to the existing one (`{old}`)." ))); } if schema.columns.iter().any(|c| c.name == new) { return Err(DbError::Unsupported(format!( "column `{table}.{new}` already exists; pick a different name." ))); } let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let ddl = format!( "ALTER TABLE {tbl} RENAME COLUMN {old_c} TO {new_c};", tbl = quote_ident(table), old_c = quote_ident(old), new_c = quote_ident(new), ); debug!(ddl = %ddl, "rename_column"); tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; // Mirror the rename into __rdbms_playground_columns. tx.execute( &format!( "UPDATE {META_TABLE} SET column_name = ?1 \ WHERE table_name = ?2 AND column_name = ?3;" ), [new, table, old], ) .map_err(DbError::from_rusqlite)?; // Mirror into __rdbms_playground_relationships on // BOTH sides — a column may be the parent endpoint or // the child endpoint (or, for self-referencing tables, // both). tx.execute( &format!( "UPDATE {REL_TABLE} SET parent_column = ?1 \ WHERE parent_table = ?2 AND parent_column = ?3;" ), [new, table, old], ) .map_err(DbError::from_rusqlite)?; tx.execute( &format!( "UPDATE {REL_TABLE} SET child_column = ?1 \ WHERE child_table = ?2 AND child_column = ?3;" ), [new, table, old], ) .map_err(DbError::from_rusqlite)?; let description = do_describe_table(conn, table)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } /// Rename a table (ADR-0035 §6, sub-phase 4h) — the one genuinely new /// low-level op in Phase 4. /// /// Uses SQLite's native `ALTER TABLE … RENAME TO` (structure-preserving, /// so no rebuild), which also rewrites references to the old name in /// other tables' FK declarations and inside the renamed table's own /// CHECK / self-FK definitions (the engine's modern, non-legacy /// behaviour). We then reconcile everything the engine does *not* know /// about, all in one transaction (commit-db-last, ADR-0015 §6): /// /// 1. every metadata row that names the table — `__rdbms_playground_columns` /// (`table_name`), **both ends** of `__rdbms_playground_relationships` /// (`parent_table` *and* `child_table`, covering a self-referential /// table), and `__rdbms_playground_table_checks` (`table_name`); /// 2. CHECK *text* that qualifies a column with the old table name /// (`T.age` → `U.age`), in both metadata tables — the live schema was /// already rewritten, so the stored text must match or a rebuild fails; /// 3. the CSV file, via the existing persistence rewrite+delete path /// (`rewritten_tables = [new]`, `deleted_tables = [old]`). /// /// Auto-named indexes and relationships keep their (now stale but /// functional) names — only the table-name *columns* update (ADR-0035 §6 /// scope; user-confirmed). One undo step (the worker's whole-project /// snapshot). fn do_rename_table( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, old: &str, new: &str, ) -> Result { reject_internal_table_name(new)?; // Canonicalize the source to its stored case (and refuse a // non-existent / internal source as "no such table") — so a // case-variant source name still resolves to the real table and the // metadata UPDATEs below match the stored case. let canonical_old = require_canonical_table(conn, old)?; let old = canonical_old.as_str(); if old == new { return Err(DbError::Unsupported(format!( "rename: new name is identical to the existing one (`{old}`)." ))); } // The database matches table names case-insensitively, so collision // checks must be case-insensitive too — otherwise the native rename // surfaces a raw engine collision error (ADR-0035 §9). A target that // differs from the source only in capitalization is a no-op the engine // cannot perform; a target colliding with a *different* table is the // ordinary "already exists" refusal. if old.eq_ignore_ascii_case(new) { return Err(DbError::Unsupported(format!( "`{old}` and `{new}` differ only in capitalization; the database \ treats them as the same table, so there is nothing to rename." ))); } let tables = do_list_tables(conn)?; if tables.iter().any(|t| t.eq_ignore_ascii_case(new)) { return Err(DbError::Unsupported(format!( "table `{new}` already exists; pick a different name." ))); } let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let ddl = format!( "ALTER TABLE {old_t} RENAME TO {new_t};", old_t = quote_ident(old), new_t = quote_ident(new), ); debug!(ddl = %ddl, "rename_table"); tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; // (1) Rename the table name in every metadata row that names it. tx.execute( &format!("UPDATE {META_TABLE} SET table_name = ?1 WHERE table_name = ?2;"), [new, old], ) .map_err(DbError::from_rusqlite)?; // Both relationship ends — parent and child — so an FK parent, an FK // child, and a self-referencing table are all covered. The // relationship `name` is left as-is (auto-names embed the old table // name; stale-but-functional per the user decision, like index names). tx.execute( &format!("UPDATE {REL_TABLE} SET parent_table = ?1 WHERE parent_table = ?2;"), [new, old], ) .map_err(DbError::from_rusqlite)?; tx.execute( &format!("UPDATE {REL_TABLE} SET child_table = ?1 WHERE child_table = ?2;"), [new, old], ) .map_err(DbError::from_rusqlite)?; tx.execute( &format!("UPDATE {CHECK_TABLE} SET table_name = ?1 WHERE table_name = ?2;"), [new, old], ) .map_err(DbError::from_rusqlite)?; // (2) Reconcile CHECK *text* that qualifies a reference with the old // table name. Read the rows (now keyed by `new`), rewrite, write back // only when changed — the common unqualified CHECK is a no-op. let col_checks: Vec<(String, String)> = { let mut stmt = tx .prepare(&format!( "SELECT column_name, check_expr FROM {META_TABLE} \ WHERE table_name = ?1 AND check_expr IS NOT NULL;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([new], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .map_err(DbError::from_rusqlite)?; let mut v = Vec::new(); for row in rows { v.push(row.map_err(DbError::from_rusqlite)?); } v }; for (column_name, expr) in col_checks { let rewritten = rewrite_check_table_qualifier(&expr, old, new); if rewritten != expr { tx.execute( &format!( "UPDATE {META_TABLE} SET check_expr = ?1 \ WHERE table_name = ?2 AND column_name = ?3;" ), rusqlite::params![rewritten, new, column_name], ) .map_err(DbError::from_rusqlite)?; } } let table_checks: Vec<(i64, String)> = { let mut stmt = tx .prepare(&format!( "SELECT seq, check_expr FROM {CHECK_TABLE} WHERE table_name = ?1;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([new], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) .map_err(DbError::from_rusqlite)?; let mut v = Vec::new(); for row in rows { v.push(row.map_err(DbError::from_rusqlite)?); } v }; for (seq, expr) in table_checks { let rewritten = rewrite_check_table_qualifier(&expr, old, new); if rewritten != expr { tx.execute( &format!( "UPDATE {CHECK_TABLE} SET check_expr = ?1 \ WHERE table_name = ?2 AND seq = ?3;" ), rusqlite::params![rewritten, new, seq], ) .map_err(DbError::from_rusqlite)?; } } let description = do_describe_table(conn, new)?; // (3) CSV follows the table: write `data/.csv` from the renamed // table (read in-tx by its new name) and delete `data/.csv` — the // existing rewrite+delete path; an empty table writes no CSV on either // side. `schema_dirty` rewrites `project.yaml`, which reflects the new // name automatically. let changes = Changes { schema_dirty: true, rewritten_tables: vec![new.to_string()], deleted_tables: vec![old.to_string()], }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } /// Change a column's type. /// /// Change a column's user-facing type, per ADR-0017's per-cell /// transformer model. /// /// SQLite's `ALTER TABLE` cannot change column types, so this /// routes through the rebuild-table primitive (ADR-0013) with a /// row-by-row transformation step inserted between read-out and /// write-back. /// /// Refusal preconditions (in order): /// /// 1. Target is `serial` (auto-increment is create-table-only). /// 2. Source ↔ target is statically refused per the matrix /// (same-type, blob, date↔datetime, undefined cross-domain). /// 3. Column is the *child* side of any relationship (outbound /// FK) — drop the relationship first. /// 4. Column has any inbound FK (parent side) and the new type /// would change `fk_target_type()` (ADR-0017 §4.1) — which /// means referencing FK columns in other tables would need a /// cascading type change v1 doesn't perform. /// /// Then per the mode: /// /// - `Default`: dry-run; refuse on any incompatible cell; /// refuse on any lossy cell (with a hint about /// `--force-conversion`); enforce uniqueness for PK / shortid /// columns; rebuild with transformed values. /// - `ForceConversion`: same as Default except lossy cells are /// accepted (incompatible cells still refuse). /// - `DontConvert`: skip the entire client-side layer, identity /// copy via `INSERT…SELECT`; engine-level errors are wrapped /// in friendly form (ADR-0002 user-facing posture). fn do_change_column_type( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, column: &str, ty: Type, mode: ChangeColumnMode, ) -> Result { // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // column executors. Closes the simple `change column` exposure and // the SQL `ALTER COLUMN TYPE` decomposition target (ADR-0035 §4f); // user-confirmed 2026-05-25. let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; let col_info = old_schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {table}.{column}"), kind: SqliteErrorKind::NoSuchColumn, })?; let src_ty = col_info.user_type.ok_or_else(|| { DbError::Unsupported(format!( "cannot change type of `{table}.{column}`: column has no \ user-facing type metadata." )) })?; // (1) + (2): static refusals via the matrix. if let Some(reason) = type_change::static_refusal(src_ty, ty) { return Err(DbError::Unsupported(reason)); } // (3): outbound FK (column is child side of any relationship). let outbound_count: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM {REL_TABLE} \ WHERE child_table = ?1 AND child_column = ?2;" ), rusqlite::params![table, column], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if outbound_count > 0 { return Err(DbError::Unsupported(format!( "cannot change the type of `{table}.{column}` — a relationship \ uses it as a foreign key, and changing its type could break \ the link to the table it references. Drop the relationship \ first, then change the type." ))); } // (4): inbound FKs (column is parent / target of any FK). let inbound_count: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM {REL_TABLE} \ WHERE parent_table = ?1 AND parent_column = ?2;" ), rusqlite::params![table, column], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if inbound_count > 0 && src_ty.fk_target_type() != ty.fk_target_type() { return Err(DbError::Unsupported(format!( "`{table}.{column}` is referenced by {inbound_count} relationship(s); \ changing its type to `{ty}` would change the type that \ referencing columns require — drop those relationships first \ or pick a target type whose foreign-key shape matches the \ current one." ))); } // Build new_schema: same as old, but the target column's // user_type / sqlite_type are updated. PK / notnull flags // carry over unchanged. UNIQUE handling per ADR-0018 §4: // // - Non-PK serial / shortid target: gain UNIQUE. // - Reverse direction (e.g. serial → int): keep whatever // UNIQUE flag was already present (ADR-0018 §4 "leaves // UNIQUE in place"), which the clone() above already // does. let mut new_schema = old_schema.clone(); { let target = new_schema .columns .iter_mut() .find(|c| c.name == column) .expect("column existence checked above"); target.user_type = Some(ty); target.sqlite_type = ty.sqlite_strict_type().to_string(); if matches!(ty, Type::Serial | Type::ShortId) && !target.primary_key { target.unique = true; // Auto-generated columns are non-null by contract // (ADR-0018 §3). Set the flag here so schema_to_ddl // emits NOT NULL during the rebuild. target.notnull = true; } } let ty_keyword = ty.keyword(); let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { tx.execute( &format!( "UPDATE {META_TABLE} SET user_type = ?1 \ WHERE table_name = ?2 AND column_name = ?3;" ), [ty_keyword, table, column], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) }; let client_side = match mode { ChangeColumnMode::DontConvert => { // Skip the client-side layer entirely. The engine's // STRICT typing will accept or refuse cells; we wrap // any error in a friendly message (ADR-0017 §5, // ADR-0002 user-facing posture). rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates) .map_err(|e| { let ctx = crate::friendly::TranslateContext { operation: Some(crate::friendly::Operation::ChangeColumnType), table: Some(table.to_string()), column: Some(column.to_string()), src_type: Some(src_ty), target_type: Some(ty), ..crate::friendly::TranslateContext::default() }; let rendered = crate::friendly::translate_error(&e, &ctx).render(); DbError::Unsupported(rendered) })?; None } ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some( run_change_column_with_dry_run( conn, table, column, src_ty, ty, mode, &old_schema, &new_schema, metadata_updates, )?, ), }; // Client-side note fires when at least one cell was // materially transformed (ADR-0017 §6) OR at least one // null cell was auto-filled (ADR-0018 §9). A pure metadata // change (zero non-identity outcomes, no auto-fill) emits // no note. let client_side = client_side.filter(|note| note.transformed > 0 || note.auto_filled > 0); let description = do_describe_table(conn, table)?; Ok(ChangeColumnTypeResult { description, client_side, }) } /// Read every row from the source table, classify the target /// column's cell via the transformer matrix, refuse on /// incompatibles or lossy (per `mode`), check uniqueness for /// PK / shortid, and finally rebuild the table with the /// transformed values bound row-by-row. /// /// Returns the per-row counts feeding the `[client-side]` /// success note (ADR-0017 §6). #[allow(clippy::too_many_arguments)] fn run_change_column_with_dry_run( conn: &Connection, table: &str, column: &str, src_ty: Type, target_ty: Type, mode: ChangeColumnMode, old_schema: &ReadSchema, new_schema: &ReadSchema, metadata_updates: M, ) -> Result where M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, { use rusqlite::types::Value as RV; use type_change::CellOutcome; let pk_columns: Vec = old_schema .columns .iter() .filter(|c| c.primary_key) .map(|c| c.name.clone()) .collect(); if pk_columns.is_empty() { return Err(DbError::Unsupported(format!( "cannot run change-column dry run on `{table}` — table has no \ primary key; use `--dont-convert` to bypass the client-side \ layer." ))); } // Read every row's data: the target column's value, plus the // PK column values (used for diagnostic identifiers and for // re-binding the row during rebuild). let all_columns: Vec = old_schema.columns.iter().map(|c| c.name.clone()).collect(); let target_idx = all_columns .iter() .position(|c| c == column) .expect("column existence checked above"); let pk_indices: Vec = pk_columns .iter() .map(|pk| all_columns.iter().position(|c| c == pk).expect("pk in cols")) .collect(); let select_cols = all_columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let order_by = pk_columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let select_sql = format!( "SELECT {select_cols} FROM {tbl} ORDER BY {order_by};", tbl = quote_ident(table), ); let mut rows: Vec> = Vec::new(); { let mut stmt = conn.prepare(&select_sql).map_err(DbError::from_rusqlite)?; let mut sql_rows = stmt.query([]).map_err(DbError::from_rusqlite)?; while let Some(r) = sql_rows.next().map_err(DbError::from_rusqlite)? { let mut row: Vec = Vec::with_capacity(all_columns.len()); for i in 0..all_columns.len() { let v: RV = r.get(i).map_err(DbError::from_rusqlite)?; row.push(v); } rows.push(row); } } // Classify every row's target cell. Null cells targeting // an auto-generated type get a placeholder outcome here // and are filled in below once the full set of non-null // values is known (so serial sequencing can start at // `MAX + 1`, and shortid generation can avoid collisions // against existing values). let auto_filling = matches!(target_ty, Type::Serial | Type::ShortId); let mut outcomes: Vec = Vec::with_capacity(rows.len()); for row in &rows { let pk_values: Vec = pk_indices.iter().map(|i| row[*i].clone()).collect(); let original = row[target_idx].clone(); let (outcome, is_auto_fill) = if auto_filling && matches!(original, RV::Null) { // Placeholder; replaced after the loop. (CellOutcome::Clean(RV::Null), true) } else { (type_change::transform_cell(src_ty, target_ty, &original), false) }; outcomes.push(Outcome { pk_values, original, outcome, is_auto_fill, }); } // Auto-fill pass for serial / shortid targets per ADR-0018 // §3 / §7. Runs before the incompatible / lossy / collision // checks so the filled values participate in those checks // as if they were ordinary classified outcomes. if auto_filling { fill_auto_generated_cells(target_ty, &mut outcomes)?; } // Refuse on incompatibles. let incompatibles: Vec<&Outcome> = outcomes .iter() .filter(|o| matches!(o.outcome, CellOutcome::Incompatible { .. })) .collect(); if !incompatibles.is_empty() { return Err(DbError::Unsupported(render_incompatible_diagnostic( table, column, src_ty, target_ty, &pk_columns, old_schema, &incompatibles, ))); } // Refuse on lossy unless --force-conversion. let lossies: Vec<&Outcome> = outcomes .iter() .filter(|o| matches!(o.outcome, CellOutcome::Lossy { .. })) .collect(); if !lossies.is_empty() && mode != ChangeColumnMode::ForceConversion { return Err(DbError::Unsupported(render_lossy_diagnostic( table, column, src_ty, target_ty, &pk_columns, old_schema, &lossies, ))); } // Uniqueness check for any target column that will carry // a uniqueness constraint in the new schema. ADR-0017 §4.3 // covered PK + shortid; ADR-0018 §4.3-amendment extends // this to "any column that gains a UNIQUE constraint as // part of the operation" — i.e., the new schema's target // column has primary_key OR unique set. let new_target = new_schema .columns .iter() .find(|c| c.name == column) .expect("target column exists in new schema"); let uniqueness_required = new_target.primary_key || new_target.unique; if uniqueness_required && let Some(error_msg) = check_uniqueness_collisions( table, column, src_ty, target_ty, &pk_columns, old_schema, &outcomes, ) { return Err(DbError::Unsupported(error_msg)); } // All cells classified Clean or Lossy-with-force. Build the // transformed values list, indexed by row, and rebuild the // table with row-by-row binding. let transformed_values: Vec = outcomes .iter() .map(|o| match &o.outcome { CellOutcome::Clean(v) | CellOutcome::Lossy { new: v, .. } => v.clone(), CellOutcome::Incompatible { .. } => { unreachable!("incompatibles refused above") } }) .collect(); let copy_data = |tx: &rusqlite::Transaction<'_>, temp_name: &str, _orig: &str| -> Result<(), DbError> { if rows.is_empty() { return Ok(()); } let cols_csv = all_columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let placeholders = (0..all_columns.len()) .map(|i| format!("?{}", i + 1)) .collect::>() .join(", "); let insert_sql = format!( "INSERT INTO {temp} ({cols}) VALUES ({ph});", temp = quote_ident(temp_name), cols = cols_csv, ph = placeholders, ); let mut stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; for (row_idx, row) in rows.iter().enumerate() { let mut bound: Vec = row.clone(); bound[target_idx] = transformed_values[row_idx].clone(); let params: Vec<&dyn rusqlite::ToSql> = bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); stmt.execute(rusqlite::params_from_iter(params)) .map_err(DbError::from_rusqlite)?; } Ok(()) }; rebuild_table_with_copy(conn, table, new_schema, copy_data, metadata_updates)?; // Tally counts for the [client-side] note. Three kinds of // cells of interest (ADR-0017 §6 + ADR-0018 §9): // // - auto-filled: original was NULL, target type // auto-generates. Counted under `auto_filled`, NOT // `transformed` (different user-facing wording). // - transformed: original was non-null and the stored // value materially differs (storage class change // counts). // - lossy: subset of transformed; only when // --force-conversion was used. let mut transformed = 0usize; let mut lossy = 0usize; let mut auto_filled = 0usize; for (idx, o) in outcomes.iter().enumerate() { let new = &transformed_values[idx]; if o.is_auto_fill { auto_filled += 1; } else if type_change::is_non_identity(&o.original, new) { transformed += 1; } if matches!(o.outcome, CellOutcome::Lossy { .. }) { lossy += 1; } } let auto_fill_kind = if auto_filled > 0 { Some(match target_ty { Type::Serial => AutoFillKind::Serial, Type::ShortId => AutoFillKind::ShortId, _ => unreachable!("auto-fill only fires for Serial / ShortId targets"), }) } else { None }; Ok(ClientSideNote { transformed, lossy, auto_filled, auto_fill_kind, }) } /// Replace the placeholder `Clean(Null)` outcomes for null /// source cells with auto-generated values. /// /// For `Serial` targets: continue the integer sequence from /// `MAX(non-null value) + 1`. For `ShortId` targets: generate /// fresh shortids that don't collide with existing values or /// with one another (5-retry budget per cell, ADR-0018 §3). fn fill_auto_generated_cells( target_ty: Type, outcomes: &mut [Outcome], ) -> Result<(), DbError> { use rusqlite::types::Value as RV; use type_change::CellOutcome; match target_ty { Type::Serial => { let max_existing: i64 = outcomes .iter() .filter_map(|o| { if o.is_auto_fill { return None; } match &o.outcome { CellOutcome::Clean(RV::Integer(i)) => Some(*i), _ => None, } }) .max() .unwrap_or(0); let mut next = max_existing.saturating_add(1); for o in outcomes.iter_mut().filter(|o| o.is_auto_fill) { o.outcome = CellOutcome::Clean(RV::Integer(next)); next = next.saturating_add(1); } } Type::ShortId => { let existing: Vec = outcomes .iter() .filter_map(|o| { if o.is_auto_fill { return None; } match &o.outcome { CellOutcome::Clean(RV::Text(s)) => Some(s.clone()), _ => None, } }) .collect(); let auto_fill_count = outcomes.iter().filter(|o| o.is_auto_fill).count(); let new_values = generate_shortid_batch(auto_fill_count, &existing)?; for (idx, o) in outcomes .iter_mut() .filter(|o| o.is_auto_fill) .enumerate() { o.outcome = CellOutcome::Clean(new_values[idx].clone()); } } _ => { // Caller filters to Serial / ShortId; the // unreachable branch is defensive. unreachable!("fill_auto_generated_cells called with non-auto-gen target"); } } Ok(()) } /// Maximum diagnostic rows rendered per refusal (ADR-0017 §7). /// Rows beyond this collapse into a trailing `… and N more` row. const DIAGNOSTIC_ROW_CAP: usize = 100; #[allow(clippy::too_many_arguments)] fn render_lossy_diagnostic( table: &str, column: &str, src_ty: Type, target_ty: Type, pk_columns: &[String], old_schema: &ReadSchema, lossies: &[&Outcome], ) -> String { let mut headers = pk_header_cells(pk_columns); headers.extend([ crate::t!("db.diagnostic.header_from"), crate::t!("db.diagnostic.header_to"), crate::t!("db.diagnostic.header_reason"), ]); let mut alignments = pk_header_alignments(pk_columns, old_schema); alignments.extend([ type_change::is_in_matrix_alignment(src_ty), type_change::is_in_matrix_alignment(target_ty), Alignment::Left, ]); let total = lossies.len(); let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut rows: Vec> = Vec::with_capacity(visible + 1); for o in lossies.iter().take(visible) { let mut cells = pk_value_cells(&o.pk_values); let (new_str, reason) = match &o.outcome { type_change::CellOutcome::Lossy { new, reason } => { (render_value(new), reason.clone()) } _ => unreachable!("filtered to Lossy"), }; cells.push(render_value(&o.original)); cells.push(new_str); cells.push(reason); rows.push(cells); } if total > visible { rows.push(more_row(headers.len(), total - visible)); } let mut out = format!( "{}\n\n", crate::t!( "db.diagnostic.lossy_summary", table = table, column = column, src_ty = src_ty, target_ty = target_ty, total = total, ), ); for line in render_diagnostic_table(&headers, &rows, &alignments) { out.push_str(&line); out.push('\n'); } out.push('\n'); out.push_str(&crate::t!("db.diagnostic.force_conversion_hint")); out } #[allow(clippy::too_many_arguments)] fn render_incompatible_diagnostic( table: &str, column: &str, src_ty: Type, target_ty: Type, pk_columns: &[String], old_schema: &ReadSchema, incompatibles: &[&Outcome], ) -> String { let mut headers = pk_header_cells(pk_columns); headers.extend([ crate::t!("db.diagnostic.header_value"), crate::t!("db.diagnostic.header_reason"), ]); let mut alignments = pk_header_alignments(pk_columns, old_schema); alignments.extend([ type_change::is_in_matrix_alignment(src_ty), Alignment::Left, ]); let total = incompatibles.len(); let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut rows: Vec> = Vec::with_capacity(visible + 1); for o in incompatibles.iter().take(visible) { let reason = match &o.outcome { type_change::CellOutcome::Incompatible { reason } => reason.clone(), _ => unreachable!("filtered to Incompatible"), }; let mut cells = pk_value_cells(&o.pk_values); cells.push(render_value(&o.original)); cells.push(reason); rows.push(cells); } if total > visible { rows.push(more_row(headers.len(), total - visible)); } let mut out = format!( "{}\n\n", crate::t!( "db.diagnostic.incompatible_summary", table = table, column = column, src_ty = src_ty, target_ty = target_ty, total = total, ), ); for line in render_diagnostic_table(&headers, &rows, &alignments) { out.push_str(&line); out.push('\n'); } out } /// Detect post-transformation duplicates among `outcomes` and /// produce a refusal diagnostic if any exist. Returns `None` if /// uniqueness is preserved. Per ADR-0017 §4.3 collisions are /// classified incompatible — no `--force-conversion` override. fn check_uniqueness_collisions( table: &str, column: &str, src_ty: Type, target_ty: Type, pk_columns: &[String], old_schema: &ReadSchema, outcomes: &[Outcome], ) -> Option { use std::collections::BTreeMap; // Keyed by the canonical string form of the transformed // value; NULL is excluded from uniqueness collision checks // (the new column may carry NULL until C3 lands NOT NULL // constraints; treat NULLs as not-colliding). let mut groups: BTreeMap> = BTreeMap::new(); for (i, o) in outcomes.iter().enumerate() { let new = match &o.outcome { type_change::CellOutcome::Clean(v) => v, type_change::CellOutcome::Lossy { new, .. } => new, type_change::CellOutcome::Incompatible { .. } => continue, }; if matches!(new, rusqlite::types::Value::Null) { continue; } groups.entry(render_value(new)).or_default().push(i); } let collisions: Vec<(String, Vec)> = groups .into_iter() .filter(|(_, idxs)| idxs.len() >= 2) .collect(); if collisions.is_empty() { return None; } let pk_label = pk_columns.join(", "); let headers = vec![ crate::t!("db.diagnostic.header_becomes"), crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label), crate::t!("db.diagnostic.header_source_values"), ]; let alignments = vec![ type_change::is_in_matrix_alignment(target_ty), Alignment::Left, Alignment::Left, ]; let total = collisions.len(); let visible = total.min(DIAGNOSTIC_ROW_CAP); let mut rows: Vec> = Vec::with_capacity(visible + 1); for (becomes, idxs) in collisions.iter().take(visible) { let pks_csv = idxs .iter() .take(5) .map(|i| pk_value_cells_inline(&outcomes[*i].pk_values)) .collect::>() .join(", "); let pks_str = if idxs.len() > 5 { format!("{pks_csv}, …") } else { pks_csv }; let sources_csv = idxs .iter() .take(5) .map(|i| render_value(&outcomes[*i].original)) .collect::>() .join(", "); let sources_str = if idxs.len() > 5 { format!("{sources_csv}, …") } else { sources_csv }; rows.push(vec![becomes.clone(), pks_str, sources_str]); } if total > visible { rows.push(more_row(headers.len(), total - visible)); } // ColumnDescription guarantees old_schema known; not used // beyond the §4.3 wording so silence the warning. let _ = old_schema; let mut out = format!( "{}\n\n", crate::t!( "db.diagnostic.uniqueness_summary", table = table, column = column, src_ty = src_ty, target_ty = target_ty, total = total, ), ); for line in render_diagnostic_table(&headers, &rows, &alignments) { out.push_str(&line); out.push('\n'); } Some(out) } /// Outcome record for the dry-run pass. Mirrors the inner type /// inside `run_change_column_with_dry_run` so the diagnostic /// helpers can take it as a slice. struct Outcome { pk_values: Vec, original: rusqlite::types::Value, outcome: type_change::CellOutcome, /// `true` when the original cell was NULL and the target /// type carries an auto-generation contract; the `outcome` /// gets filled in with the generated value during the /// post-classification auto-fill pass. is_auto_fill: bool, } fn pk_header_cells(pk_columns: &[String]) -> Vec { pk_columns .iter() .map(|c| format!("{c} (PK)")) .collect() } fn pk_header_alignments(pk_columns: &[String], schema: &ReadSchema) -> Vec { pk_columns .iter() .map(|name| { schema .columns .iter() .find(|c| &c.name == name) .and_then(|c| c.user_type) .map_or(Alignment::Left, type_change::is_in_matrix_alignment) }) .collect() } fn pk_value_cells(values: &[rusqlite::types::Value]) -> Vec { values.iter().map(render_value).collect() } /// Single-cell inline rendering of a row's PK values, used in /// uniqueness-collision diagnostics where one cell lists many /// PK identifiers. Single-PK forms render as bare values (`5`); /// compound-PK forms render as tuples (`(1, 2)`). fn pk_value_cells_inline(values: &[rusqlite::types::Value]) -> String { if values.len() == 1 { render_value(&values[0]) } else { let parts: Vec = values.iter().map(render_value).collect(); format!("({})", parts.join(", ")) } } fn render_value(v: &rusqlite::types::Value) -> String { use rusqlite::types::Value as RV; match v { RV::Null => "(null)".to_string(), RV::Integer(i) => i.to_string(), RV::Real(r) => format!("{r}"), RV::Text(s) => s.clone(), RV::Blob(_) => "".to_string(), } } fn more_row(width: usize, more: usize) -> Vec { let mut row = vec!["…".to_string(); width]; if let Some(last) = row.last_mut() { *last = format!("… and {more} more"); } row } fn do_list_tables(conn: &Connection) -> Result, DbError> { let mut stmt = conn .prepare( "SELECT name FROM sqlite_schema \ WHERE type = 'table' \ AND name NOT LIKE 'sqlite_%' \ AND substr(name, 1, 8) != '__rdbms_' \ ORDER BY name;", ) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } /// Internal full schema of a table, sufficient to regenerate /// its `CREATE TABLE` statement during the rebuild dance. #[derive(Debug, Clone)] struct ReadSchema { columns: Vec, primary_key: Vec, foreign_keys: Vec, /// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2), /// read from the UNIQUE-constraint indexes (`origin = 'u'`). /// Single-column UNIQUE rides on `ReadColumn::unique` instead. unique_constraints: Vec>, /// Table-level CHECK constraints as raw SQL text with an optional /// name, in declaration order (ADR-0035 §4a.3, named in §4g). The /// engine reports no CHECK constraints, so these are read from /// `__rdbms_playground_table_checks` rather than PRAGMA, and echoed /// verbatim by `schema_to_ddl` on rebuild (`CONSTRAINT ` when /// named). check_constraints: Vec, } #[derive(Debug, Clone)] struct ReadColumn { name: String, sqlite_type: String, notnull: bool, primary_key: bool, /// `true` when this column carries a single-column UNIQUE /// constraint detected via `pragma_index_list` / /// `pragma_index_info` (origin = "u"). PK columns are not /// marked unique here even though PK implies UNIQUE — the /// `primary_key` flag covers that, and `schema_to_ddl` /// avoids double-emitting. Compound UNIQUE is out of scope /// for v1 (ADR-0018 OOS-6 / future C3 work). unique: bool, /// The column's `DEFAULT` expression as SQLite reports it /// (`pragma_table_info.dflt_value`) — already a SQL /// literal, echoed verbatim by `schema_to_ddl` so the /// rebuild dance preserves it (ADR-0029). default_sql: Option, /// The column's `CHECK` constraint in compiled-SQL form /// (ADR-0029 §7), read from the `check_expr` metadata /// column — `pragma_table_info` does not expose CHECK. /// Echoed verbatim by `schema_to_ddl`. check: Option, user_type: Option, } #[derive(Debug, Clone)] struct ReadForeignKey { parent_table: String, parent_column: String, child_column: String, on_delete: ReferentialAction, on_update: ReferentialAction, } fn read_schema(conn: &Connection, table: &str) -> Result { // Columns + PK from pragma_table_info, joined with our user-type metadata. let mut col_stmt = conn .prepare(&format!( "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \ pti.dflt_value, m.check_expr \ FROM pragma_table_info(?1) AS pti \ LEFT JOIN {META_TABLE} AS m \ ON m.table_name = ?1 AND m.column_name = pti.name \ ORDER BY pti.cid;" )) .map_err(DbError::from_rusqlite)?; let rows = col_stmt .query_map([table], |row| { let user_type_kw: Option = row.get(4)?; let user_type = user_type_kw.and_then(|kw| kw.parse::().ok()); Ok(ReadColumn { name: row.get(0)?, sqlite_type: row.get(1)?, notnull: row.get::<_, i64>(2)? != 0, primary_key: row.get::<_, i64>(3)? != 0, unique: false, // filled in below from pragma_index_list default_sql: row.get(5)?, check: row.get(6)?, user_type, }) }) .map_err(DbError::from_rusqlite)?; let mut columns = Vec::new(); for row in rows { columns.push(row.map_err(DbError::from_rusqlite)?); } if columns.is_empty() { return Err(DbError::Sqlite { message: format!("no such table: {table}"), kind: SqliteErrorKind::NoSuchTable, }); } let primary_key: Vec = columns .iter() .filter(|c| c.primary_key) .map(|c| c.name.clone()) .collect(); // Detect UNIQUE constraints (ADR-0018 §4, ADR-0035 §4a.2). // pragma_index_list returns one row per index; we filter to // unique indexes whose origin is "u" (a UNIQUE constraint, as // opposed to "pk" or "c"). Single-column → the column's `unique` // flag; multi-column → a composite `unique_constraints` entry. let (unique_columns, unique_constraints) = read_unique_constraints(conn, table)?; for col in &mut columns { if unique_columns.contains(&col.name) { col.unique = true; } } // Foreign keys from pragma_foreign_key_list. let mut fk_stmt = conn .prepare( "SELECT \"table\", \"from\", \"to\", on_delete, on_update \ FROM pragma_foreign_key_list(?1) \ ORDER BY id, seq;", ) .map_err(DbError::from_rusqlite)?; let fk_rows = fk_stmt .query_map([table], |row| { let on_delete_str: String = row.get(3)?; let on_update_str: String = row.get(4)?; Ok(ReadForeignKey { parent_table: row.get(0)?, child_column: row.get(1)?, parent_column: row.get(2)?, on_delete: parse_action_from_sqlite(&on_delete_str), on_update: parse_action_from_sqlite(&on_update_str), }) }) .map_err(DbError::from_rusqlite)?; let mut foreign_keys = Vec::new(); for row in fk_rows { foreign_keys.push(row.map_err(DbError::from_rusqlite)?); } // Table-level CHECK constraints (ADR-0035 §4a.3) come from their // metadata table, not PRAGMA — the engine reports no CHECKs. let check_constraints = read_table_checks(conn, table)?; Ok(ReadSchema { columns, primary_key, foreign_keys, unique_constraints, check_constraints, }) } /// Read a table's table-level CHECK constraints (ADR-0035 §4a.3, named /// in §4g) from `CHECK_TABLE`, in declaration order (`seq`). The engine /// exposes no PRAGMA for CHECK constraints, so this metadata table is /// their only source of truth. Tolerates a pre-4g project whose table /// predates the `name` column (rebuild-only migration) by reading the /// name as `None`. fn read_table_checks(conn: &Connection, table: &str) -> Result, DbError> { let has_name = check_table_has_name_column(conn)?; let sql = if has_name { format!( "SELECT check_expr, name FROM {CHECK_TABLE} \ WHERE table_name = ?1 ORDER BY seq;" ) } else { format!( "SELECT check_expr FROM {CHECK_TABLE} \ WHERE table_name = ?1 ORDER BY seq;" ) }; let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([table], |row| { let expr: String = row.get(0)?; let name: Option = if has_name { row.get(1)? } else { None }; Ok(TableCheck { name, expr }) }) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } /// Whether `CHECK_TABLE` carries the `name` column (ADR-0035 §4g). A /// pre-4g project's metadata table predates it — the column arrives on /// `rebuild` (the rebuild-only migration, user-confirmed 2026-05-25). /// Used to read names tolerantly and to refuse a *named* CHECK add on an /// un-upgraded project with a friendly "rebuild first" message rather /// than a raw engine error. fn check_table_has_name_column(conn: &Connection) -> Result { let count: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"), [], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; Ok(count > 0) } /// Whether the raw CHECK expression `check_expr` references the column /// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard). /// /// Tokenizes the expression with the shared lex helpers, **skipping /// single-quoted string literals** so an identifier that only appears /// inside a literal does not false-match, and compares each bare /// identifier case-insensitively. Playground column names are always /// valid bare identifiers (`validate_user_name` rejects the characters /// that would force quoting), so bare-identifier scanning is sufficient; /// the engine's native `DROP`/`RENAME COLUMN` remains the backstop for /// any miss. fn check_references_column(check_expr: &str, column: &str) -> bool { use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal, skip_whitespace}; let mut i = 0; while i < check_expr.len() { i = skip_whitespace(check_expr, i); if i >= check_expr.len() { break; } if let Some(((_, end), _)) = consume_string_literal(check_expr, i) { i = end; } else if let Some((start, end)) = consume_ident(check_expr, i) { if check_expr[start..end].eq_ignore_ascii_case(column) { return true; } i = end; } else { // Operator / paren / number / punctuation — advance one char. i += check_expr[i..].chars().next().map_or(1, char::len_utf8); } } false } #[cfg(test)] mod check_references_column_tests { use super::check_references_column; #[test] fn detects_a_bare_identifier() { assert!(check_references_column("price > 0", "price")); assert!(check_references_column("a < b AND b < c", "b")); } #[test] fn is_case_insensitive() { assert!(check_references_column("Price > 0", "price")); assert!(check_references_column("price > 0", "PRICE")); } #[test] fn ignores_identifier_inside_a_string_literal() { // `status` appears only inside a literal → not a reference. assert!(!check_references_column("kind <> 'status'", "status")); // A genuine reference alongside a literal is still found. assert!(check_references_column("status <> 'archived'", "status")); } #[test] fn ignores_a_longer_identifier_that_merely_contains_the_name() { // `price` is a substring of these idents but not a whole match. assert!(!check_references_column("price_cents > 0", "price")); assert!(!check_references_column("unit_price > 0", "price")); } } /// Rewrite the old table name where it is used as a **qualifier** in a /// CHECK expression (the identifier immediately followed by `.`) to the /// new name — both the bare (`old.col`) and double-quoted (`"old".col`) /// forms, case-insensitively — leaving everything else byte-for-byte /// intact (ADR-0035 §6, sub-phase 4h). /// /// **Why this is needed.** A native `ALTER TABLE old RENAME TO new` /// rewrites table-qualified column references inside the renamed table's /// *live* CHECK (`CHECK (T.age>0)` → `CHECK ("U".age>0)`), but our /// *stored* CHECK text would keep the old name and break a later rebuild /// (`schema_to_ddl` would emit `CHECK (T.age>0)` for a table now named /// `U` → "no such table T"). This keeps the stored text in step with the /// live schema. **Bounded problem:** a CHECK may reference only its own /// table's columns (SQLite forbids subqueries / other tables in a CHECK), /// so the only table qualifier that can appear is the renamed table's /// name — the rewrite target is unambiguous. /// /// Extends the [`check_references_column`] tokenizer: skips single-quoted /// string literals (a literal containing the old name is untouched), and /// a bare identifier equal to the old name but **not** used as a qualifier /// (not followed by `.` — e.g. a column literally named like the table) /// is left alone, so the common unqualified CHECK (`age > 0`) is a no-op. fn rewrite_check_table_qualifier(check_expr: &str, old: &str, new: &str) -> String { use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal}; let bytes = check_expr.as_bytes(); // Whether the next byte at `pos` begins a `.` qualifier separator. let dot_follows = |pos: usize| bytes.get(pos) == Some(&b'.'); let mut out = String::with_capacity(check_expr.len()); let mut i = 0; while i < check_expr.len() { // Single-quoted string literal — copy verbatim, never rewrite. if let Some(((start, end), _)) = consume_string_literal(check_expr, i) { out.push_str(&check_expr[start..end]); i = end; continue; } // Double-quoted identifier — scan to the closing quote (`""` is an // escaped quote). Rewrite when it is the old name used as a // qualifier; table names never contain quotes, so the raw inner // text suffices for comparison. if bytes[i] == b'"' { let mut j = i + 1; while j < check_expr.len() { if bytes[j] == b'"' { if bytes.get(j + 1) == Some(&b'"') { j += 2; // escaped quote inside the identifier continue; } break; // closing quote } j += 1; } let end = (j + 1).min(check_expr.len()); // byte after closing `"` let inner = &check_expr[i + 1..j.min(check_expr.len())]; if inner.eq_ignore_ascii_case(old) && dot_follows(end) { out.push('"'); out.push_str(new); out.push('"'); } else { out.push_str(&check_expr[i..end]); } i = end; continue; } // Bare identifier. if let Some((start, end)) = consume_ident(check_expr, i) { if check_expr[start..end].eq_ignore_ascii_case(old) && dot_follows(end) { out.push_str(new); } else { out.push_str(&check_expr[start..end]); } i = end; continue; } // Operator / paren / number / punctuation — copy one char. let ch_len = check_expr[i..].chars().next().map_or(1, char::len_utf8); out.push_str(&check_expr[i..i + ch_len]); i += ch_len; } out } #[cfg(test)] mod rewrite_check_table_qualifier_tests { use super::rewrite_check_table_qualifier; #[test] fn rewrites_a_bare_qualifier() { assert_eq!(rewrite_check_table_qualifier("T.age > 0", "T", "U"), "U.age > 0"); // multiple occurrences, table-level CHECK shape assert_eq!(rewrite_check_table_qualifier("T.a <> T.b", "T", "U"), "U.a <> U.b"); } #[test] fn is_case_insensitive_on_the_qualifier() { assert_eq!(rewrite_check_table_qualifier("t.age > 0", "T", "U"), "U.age > 0"); } #[test] fn rewrites_a_quoted_qualifier() { assert_eq!( rewrite_check_table_qualifier("\"T\".age > 0", "T", "U"), "\"U\".age > 0" ); } #[test] fn leaves_string_literals_untouched() { assert_eq!( rewrite_check_table_qualifier("note <> 'T.x' AND T.age > 0", "T", "U"), "note <> 'T.x' AND U.age > 0" ); } #[test] fn leaves_a_bare_name_that_is_not_a_qualifier() { // A column literally named like the table, not followed by `.`. assert_eq!(rewrite_check_table_qualifier("T > 0", "T", "U"), "T > 0"); } #[test] fn unqualified_check_is_a_no_op() { assert_eq!(rewrite_check_table_qualifier("age > 0", "T", "U"), "age > 0"); } #[test] fn does_not_match_a_longer_identifier() { // `Total` merely starts with `T`; not the qualifier `T`. assert_eq!( rewrite_check_table_qualifier("Total.x > 0", "T", "U"), "Total.x > 0" ); } } /// Whether any CHECK constraint on `table` references `column` — both the /// table-level CHECKs (`read_table_checks`) and the column-level CHECKs /// (`schema.columns[].check`), the guard for drop/rename column /// (ADR-0035 §4e). /// /// `include_self` controls whether the column's *own* column-level CHECK /// counts: a RENAME rewrites the live CHECK but leaves the stored text /// stale (drift) even for a self-check, so it must be included; a DROP /// removes the column's own CHECK alongside it, so it is excluded (only a /// *cross-referencing* CHECK blocks the drop). fn column_referenced_by_check( conn: &Connection, table: &str, schema: &ReadSchema, column: &str, include_self: bool, ) -> Result { for check in read_table_checks(conn, table)? { if check_references_column(&check.expr, column) { return Ok(true); } } for col in &schema.columns { if !include_self && col.name == column { continue; } if let Some(check) = &col.check && check_references_column(check, column) { return Ok(true); } } Ok(false) } /// Read the user-created indexes on `table` (ADR-0025). /// /// `pragma_index_list` reports every index; we keep only those /// with origin `c` (a `CREATE INDEX` statement) and skip partial /// indexes — the playground never creates partial indexes, and /// surfacing the automatic PK / UNIQUE indexes as user indexes /// would be misleading. Results are ordered by index name for /// stable rendering. fn read_table_indexes(conn: &Connection, table: &str) -> Result, DbError> { let mut list_stmt = conn .prepare( "SELECT name, \"unique\", origin, partial \ FROM pragma_index_list(?1) \ ORDER BY name;", ) .map_err(DbError::from_rusqlite)?; let metas = list_stmt .query_map([table], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0, row.get::<_, String>(2)?, row.get::<_, i64>(3)? != 0, )) }) .map_err(DbError::from_rusqlite)?; let mut keep: Vec<(String, bool)> = Vec::new(); for meta in metas { let (name, unique, origin, partial) = meta.map_err(DbError::from_rusqlite)?; if origin == "c" && !partial { keep.push((name, unique)); } } let mut out = Vec::with_capacity(keep.len()); for (name, unique) in keep { let columns = read_index_columns(conn, &name)?; out.push(IndexInfo { name, columns, unique, }); } Ok(out) } /// The indexed columns of `index`, in index order. fn read_index_columns(conn: &Connection, index: &str) -> Result, DbError> { let mut stmt = conn .prepare( "SELECT name FROM pragma_index_info(?1) ORDER BY seqno;", ) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([index], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } fn parse_action_from_sqlite(s: &str) -> ReferentialAction { // SQLite stores the action keywords in upper-case form // ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT"). s.parse::() .unwrap_or(ReferentialAction::NoAction) } /// Read the set of column names carrying a single-column UNIQUE /// constraint on `table`, via `pragma_index_list` + /// `pragma_index_info`. Filters to indexes whose `origin` is /// `"u"` (a UNIQUE constraint, not a PK-implied or CHECK /// auto-index) and which cover exactly one column. Compound /// UNIQUE is deferred to a future ADR (out of scope for ADR-0018). /// Read the table's `UNIQUE` constraints (ADR-0018 §4, ADR-0035 /// §4a.2) from the constraint-backing indexes (`origin = 'u'`). /// Returns `(single_column_names, composite_constraints)`: a /// single-column UNIQUE rides on the column's `unique` flag, while a /// multi-column UNIQUE becomes a `Vec` of its columns (in /// index order). fn read_unique_constraints( conn: &Connection, table: &str, ) -> Result<(std::collections::HashSet, Vec>), DbError> { let mut single: std::collections::HashSet = std::collections::HashSet::new(); let mut composite: Vec> = Vec::new(); let mut idx_stmt = conn .prepare( "SELECT name, \"unique\", origin \ FROM pragma_index_list(?1);", ) .map_err(DbError::from_rusqlite)?; let idx_rows = idx_stmt .query_map([table], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0, row.get::<_, String>(2)?, )) }) .map_err(DbError::from_rusqlite)?; let indexes: Vec<(String, bool, String)> = idx_rows .collect::, _>>() .map_err(DbError::from_rusqlite)?; for (idx_name, is_unique, origin) in indexes { if !is_unique || origin != "u" { continue; } let mut info_stmt = conn .prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno;") .map_err(DbError::from_rusqlite)?; let cols: Vec = info_stmt .query_map([&idx_name], |row| row.get::<_, String>(0)) .map_err(DbError::from_rusqlite)? .collect::, _>>() .map_err(DbError::from_rusqlite)?; match cols.len() { 0 => {} 1 => { single.insert(cols.into_iter().next().expect("len 1")); } _ => composite.push(cols), } } Ok((single, composite)) } /// Engine-neutral display/address name for an anonymous composite UNIQUE /// constraint (ADR-0035 Amendment 1): `unique__…`. A pure /// function of the column list — recomputed wherever the name is shown /// (`describe`) or matched (`ALTER TABLE … DROP CONSTRAINT `), so /// nothing is persisted; the constraint stays a bare column-list in our /// model and the §4g anonymity decision is intact. pub(crate) fn unique_constraint_name(cols: &[String]) -> String { format!("unique_{}", cols.join("_")) } /// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during /// the rebuild dance. fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String { let mut clauses: Vec = Vec::new(); // The single-column-INTEGER-PK case must be inline (`PRIMARY // KEY` on the column itself) so SQLite gives it rowid-alias // semantics. Compound or non-INTEGER PKs go to a table-level // constraint. let single_inline_pk = schema.primary_key.len() == 1 && !schema.columns.is_empty() && schema.columns[0].primary_key && schema.primary_key[0] == schema.columns[0].name; for col in &schema.columns { let mut clause = format!( "{ident} {sqlite_type}", ident = quote_ident(&col.name), sqlite_type = col.sqlite_type, ); if col.notnull { clause.push_str(" NOT NULL"); } if single_inline_pk && col.primary_key { clause.push_str(" PRIMARY KEY"); } // Inline UNIQUE for columns flagged unique (ADR-0018 §4, // ADR-0029 §9). A *single-column* PK is already UNIQUE // via PRIMARY KEY, so suppress a redundant index there; // a *compound*-PK member is not individually unique, so // an explicit UNIQUE on it is a real, distinct rule. let single_column_pk = schema.primary_key.len() == 1; if col.unique && !(col.primary_key && single_column_pk) { clause.push_str(" UNIQUE"); } // ADR-0029 DEFAULT — echoed verbatim from the value // SQLite reported, so the rebuild dance preserves it. if let Some(default_sql) = &col.default_sql { clause.push_str(" DEFAULT "); clause.push_str(default_sql); } // ADR-0029 CHECK — echoed verbatim from the compiled // SQL stored in the `check_expr` metadata column. if let Some(check) = &col.check { clause.push_str(" CHECK ("); clause.push_str(check); clause.push(')'); } clauses.push(clause); } if !single_inline_pk && !schema.primary_key.is_empty() { let pk_idents: Vec = schema.primary_key.iter().map(|n| quote_ident(n)).collect(); clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", "))); } // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) — // emitted identically to `do_create_table` so a created table and // its rebuilt form match. for cols in &schema.unique_constraints { let idents: Vec = cols.iter().map(|n| quote_ident(n)).collect(); clauses.push(format!("UNIQUE ({})", idents.join(", "))); } // Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim // from the raw SQL stored in the metadata table, emitted identically // to `do_create_table` (the §6.1 two-generators rule). A named CHECK // (ADR-0035 §4g) re-emits its `CONSTRAINT ` prefix so the name // round-trips through a rebuild. for check in &schema.check_constraints { match &check.name { Some(name) => clauses.push(format!( "CONSTRAINT {ident} CHECK ({expr})", ident = quote_ident(name), expr = check.expr, )), None => clauses.push(format!("CHECK ({expr})", expr = check.expr)), } } for fk in &schema.foreign_keys { clauses.push(format!( "FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \ ON DELETE {od} ON UPDATE {ou}", child = quote_ident(&fk.child_column), parent_table = quote_ident(&fk.parent_table), parent_col = quote_ident(&fk.parent_column), od = fk.on_delete.sql_clause(), ou = fk.on_update.sql_clause(), )); } format!( "CREATE TABLE {ident} ({clauses}) STRICT;", ident = quote_ident(table), clauses = clauses.join(", "), ) } /// The rebuild-table dance. Replaces `table` with a fresh table /// constructed from `new_schema`, then runs a caller-provided /// `copy_data` step to populate it from the old table. The /// `metadata_updates` closure runs inside the same transaction /// after the data copy. /// /// Most callers want the default INSERT…SELECT copy — they /// should use [`rebuild_table`] which wraps this with that copy /// strategy. The custom-copy form is reserved for `change column /// type` (ADR-0017), which transforms one column's values /// row-by-row before binding them. /// /// Per the official SQLite ALTER-via-rebuild recipe: foreign-key /// enforcement must be temporarily disabled at session level, /// not just deferred, because the connection-level pragma is a /// no-op inside transactions. fn rebuild_table_with_copy( conn: &Connection, table: &str, new_schema: &ReadSchema, copy_data: C, metadata_updates: M, ) -> Result<(), DbError> where C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>, M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, { // foreign_keys=OFF must be set *outside* a transaction. conn.execute_batch("PRAGMA foreign_keys = OFF;") .map_err(DbError::from_rusqlite)?; let result = (|| -> Result<(), DbError> { let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let temp_name = format!("__rdbms_rebuild_{table}"); // Defensive: drop any leftover rebuild table from a // previous failed attempt. tx.execute_batch(&format!( "DROP TABLE IF EXISTS {ident};", ident = quote_ident(&temp_name) )) .map_err(DbError::from_rusqlite)?; let create_temp = schema_to_ddl(&temp_name, new_schema); tx.execute_batch(&create_temp) .map_err(DbError::from_rusqlite)?; copy_data(&tx, &temp_name, table)?; // Capture the table's user indexes before the drop — // `DROP TABLE` discards them (ADR-0025). They are // recreated verbatim after the rename: every caller of // this primitive preserves the column set, so the index // column references stay valid. let captured_indexes = read_table_indexes(&tx, table)?; tx.execute_batch(&format!( "DROP TABLE {ident};", ident = quote_ident(table) )) .map_err(DbError::from_rusqlite)?; tx.execute_batch(&format!( "ALTER TABLE {temp} RENAME TO {final_name};", temp = quote_ident(&temp_name), final_name = quote_ident(table), )) .map_err(DbError::from_rusqlite)?; for index in &captured_indexes { let cols = index .columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let unique_kw = if index.unique { "UNIQUE " } else { "" }; tx.execute_batch(&format!( "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});", idx = quote_ident(&index.name), tbl = quote_ident(table), )) .map_err(DbError::from_rusqlite)?; } metadata_updates(&tx)?; // Verify referential integrity before committing. Any // returned rows mean a FK violation persisted. let mut check = tx .prepare("PRAGMA foreign_key_check;") .map_err(DbError::from_rusqlite)?; let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? { return Err(DbError::Sqlite { message: format!( "foreign-key check failed after rebuild of `{table}`; \ existing data violates the new constraint" ), kind: SqliteErrorKind::Other, }); } drop(rows); drop(check); tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) })(); // Always re-enable foreign_keys, even on error. let pragma_result = conn .execute_batch("PRAGMA foreign_keys = ON;") .map_err(DbError::from_rusqlite); result.and(pragma_result) } /// The default rebuild-table strategy: column-by-name copy via /// `INSERT INTO new (cols) SELECT cols FROM old`, where `cols` /// is the intersection of old and new column names. fn rebuild_table( conn: &Connection, table: &str, old_schema: &ReadSchema, new_schema: &ReadSchema, metadata_updates: F, ) -> Result<(), DbError> where F: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, { let copy_cols: Vec = new_schema .columns .iter() .filter(|c| old_schema.columns.iter().any(|oc| oc.name == c.name)) .map(|c| c.name.clone()) .collect(); rebuild_table_with_copy( conn, table, new_schema, |tx, temp_name, orig| { if copy_cols.is_empty() { return Ok(()); } let cols_csv = copy_cols .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let copy_sql = format!( "INSERT INTO {temp} ({cols}) SELECT {cols} FROM {orig};", temp = quote_ident(temp_name), cols = cols_csv, orig = quote_ident(orig), ); tx.execute_batch(©_sql).map_err(DbError::from_rusqlite) }, metadata_updates, ) } /// Auto-name a relationship per ADR-0013 /// (`__to__`, reading in the declared /// direction) when `name` is `None`; otherwise use the supplied name. /// Shared by the DSL `add relationship` path and advanced-mode SQL /// `CREATE TABLE` foreign keys (ADR-0035 §5). fn resolve_relationship_name( name: Option<&str>, parent_table: &str, parent_column: &str, child_table: &str, child_column: &str, ) -> String { name.map_or_else( || format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"), ToString::to_string, ) } /// Reject a relationship name that collides with an existing one (the /// `name` column is UNIQUE, ADR-0013). Engine-neutral message. fn ensure_relationship_name_unique(conn: &Connection, name: &str) -> Result<(), DbError> { let collision: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"), [name], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if collision > 0 { return Err(DbError::Unsupported(format!( "a relationship named `{name}` already exists. \ Pick a different name or drop the existing one first." ))); } Ok(()) } /// Insert one relationship metadata row into `REL_TABLE` (ADR-0013). /// Shared by `add relationship` (inside its rebuild transaction) and /// SQL `CREATE TABLE` foreign keys (inside the create transaction); /// `conn` may be a `&Transaction` (deref-coerces to `&Connection`). #[allow(clippy::too_many_arguments)] fn insert_relationship_metadata( conn: &Connection, name: &str, parent_table: &str, parent_column: &str, child_table: &str, child_column: &str, on_delete: ReferentialAction, on_update: ReferentialAction, ) -> Result<(), DbError> { conn.execute( &format!( "INSERT INTO {REL_TABLE} \ (name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);" ), [ name, parent_table, parent_column, child_table, child_column, on_delete.keyword(), on_update.keyword(), ], ) .map_err(DbError::from_rusqlite)?; Ok(()) } /// Validate that an FK child column's type is compatible with the /// referenced parent column's type — it must equal the parent type's /// `fk_target_type()` (ADR-0011). Engine-neutral mismatch error. fn check_fk_type_compat( parent_table: &str, parent_column: &str, parent_type: Type, child_table: &str, child_column: &str, child_type: Type, ) -> Result<(), DbError> { let expected = parent_type.fk_target_type(); if child_type != expected { return Err(DbError::Unsupported(format!( "type mismatch: `{child_table}.{child_column}` is `{child_type}` but \ a foreign key referencing `{parent_table}.{parent_column}` \ (`{parent_type}`) requires `{expected}`. \ Either change the column type, or pick a different FK column." ))); } Ok(()) } /// A `CREATE TABLE` foreign key after resolution + validation /// (ADR-0035 §5, sub-phase 4b): the bare-`REFERENCES` parent column is /// resolved, the relationship name is decided, and PK-target / /// type-compat are checked. struct ResolvedFk { name: String, child_column: String, parent_table: String, parent_column: String, on_delete: ReferentialAction, on_update: ReferentialAction, } /// Resolve + validate every foreign key declared in a `CREATE TABLE` /// (ADR-0035 §5, sub-phase 4b) **before** the table is built, so an /// invalid reference aborts cleanly. A self-referencing FK (parent is /// the table being created) is validated against the columns/PK being /// defined; any other parent must already exist. The bare /// `REFERENCES ` form resolves to the parent's single-column /// PK (composite → error). Reuses the relationship validation/naming /// helpers shared with `do_add_relationship`. fn resolve_create_table_fks( conn: &Connection, table_name: &str, columns: &[ColumnSpec], primary_key: &[String], foreign_keys: &[SqlForeignKey], ) -> Result, DbError> { let mut out = Vec::with_capacity(foreign_keys.len()); for fk in foreign_keys { // The parent's PK column list + a (name -> user type) lookup. // A self-reference reads the in-statement specs (the table does // not exist yet); any other parent must already exist. let (parent_pk, parent_cols): (Vec, Vec<(String, Option)>) = if fk.parent_table == table_name { ( primary_key.to_vec(), columns.iter().map(|c| (c.name.clone(), Some(c.ty))).collect(), ) } else { let ps = read_schema(conn, &fk.parent_table)?; ( ps.primary_key.clone(), ps.columns .iter() .map(|c| (c.name.clone(), c.user_type)) .collect(), ) }; // Explicit referenced column, or the parent's single-column PK // for the bare `REFERENCES ` form. let parent_column = match &fk.parent_column { Some(c) => c.clone(), None => { if parent_pk.len() == 1 { parent_pk[0].clone() } else { return Err(DbError::Unsupported(format!( "`{parent}` has a composite primary key, so a bare \ reference is ambiguous — name the referenced column, \ e.g. `REFERENCES {parent}()`.", parent = fk.parent_table, ))); } } }; // The referenced column must be a primary key (ADR-0011/0013). if !parent_pk.contains(&parent_column) { return Err(DbError::Unsupported(format!( "column `{}.{}` is not a primary key. Foreign keys must \ reference a primary key (UNIQUE-target FKs land in a later \ iteration).", fk.parent_table, parent_column ))); } let parent_type = parent_cols .iter() .find(|(n, _)| n == &parent_column) .and_then(|(_, t)| *t) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {}.{}", fk.parent_table, parent_column), kind: SqliteErrorKind::NoSuchColumn, })?; // The child column must be one of the columns being defined. let child = columns .iter() .find(|c| c.name == fk.child_column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {}.{}", table_name, fk.child_column), kind: SqliteErrorKind::NoSuchColumn, })?; check_fk_type_compat( &fk.parent_table, &parent_column, parent_type, table_name, &fk.child_column, child.ty, )?; let resolved_name = resolve_relationship_name( fk.name.as_deref(), &fk.parent_table, &parent_column, table_name, &fk.child_column, ); ensure_relationship_name_unique(conn, &resolved_name)?; out.push(ResolvedFk { name: resolved_name, child_column: fk.child_column.clone(), parent_table: fk.parent_table.clone(), parent_column, on_delete: fk.on_delete, on_update: fk.on_update, }); } Ok(out) } #[allow(clippy::too_many_arguments)] fn do_add_relationship( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, name: Option<&str>, parent_table: &str, parent_column: &str, child_table: &str, child_column: &str, on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, ) -> Result { // Canonicalize both endpoints to their stored case (and refuse a // non-existent / internal `__rdbms_*` table as "no such table"), like // the sibling schema-mutation executors — so the relationship metadata // stores the stored-case names and `describe` / rebuild match them. // Closes the simple `add 1:n relationship` exposure and the SQL `ALTER // TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g). let canonical_parent = require_canonical_table(conn, parent_table)?; let parent_table = canonical_parent.as_str(); let canonical_child = require_canonical_table(conn, child_table)?; let child_table = canonical_child.as_str(); // 1. Read parent schema; verify the referenced column is a PK. let parent_schema = read_schema(conn, parent_table)?; let parent_col = parent_schema .columns .iter() .find(|c| c.name == parent_column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {parent_table}.{parent_column}"), kind: SqliteErrorKind::NoSuchColumn, })?; if !parent_col.primary_key { return Err(DbError::Unsupported(format!( "column `{parent_table}.{parent_column}` is not a primary key. \ Foreign keys must reference a primary key (UNIQUE-target FKs \ land in a later iteration)." ))); } // 2. Read child schema; verify the FK column or auto-create. let mut child_schema = read_schema(conn, child_table)?; let needs_create_column = child_schema .columns .iter() .all(|c| c.name != child_column); if needs_create_column && !create_fk { return Err(DbError::Unsupported(format!( "column `{child_table}.{child_column}` does not exist. \ Add it first, or use `--create-fk` to create it automatically." ))); } // 3. Determine child column type. Either the existing one's // user_type, or the parent's fk_target_type for auto-create. let parent_user_type = parent_col.user_type.ok_or_else(|| DbError::Unsupported( "parent column has no user type metadata".to_string(), ))?; let expected_child_type = parent_user_type.fk_target_type(); if needs_create_column { // Synthesise the column row for the new schema. child_schema.columns.push(ReadColumn { name: child_column.to_string(), sqlite_type: expected_child_type.sqlite_strict_type().to_string(), notnull: false, primary_key: false, unique: false, default_sql: None, check: None, user_type: Some(expected_child_type), }); } else { // Validate type compatibility against the existing column. let child_col = child_schema .columns .iter() .find(|c| c.name == child_column) .expect("checked above"); let actual = child_col.user_type.ok_or_else(|| DbError::Unsupported( "child column has no user type metadata".to_string(), ))?; check_fk_type_compat( parent_table, parent_column, parent_user_type, child_table, child_column, actual, )?; } // 4. Determine relationship name (auto-gen or supplied) and // check uniqueness against the metadata table. let resolved_name = resolve_relationship_name(name, parent_table, parent_column, child_table, child_column); ensure_relationship_name_unique(conn, &resolved_name)?; // 5. Build the new schema with the FK appended. let mut new_schema = child_schema.clone(); new_schema.foreign_keys.push(ReadForeignKey { parent_table: parent_table.to_string(), parent_column: parent_column.to_string(), child_column: child_column.to_string(), on_delete, on_update, }); // 6. Rebuild, with metadata updates inside the transaction. let column_user_type_kw = expected_child_type.keyword(); rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| { if needs_create_column { tx.execute( &format!( "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ VALUES (?1, ?2, ?3);" ), [child_table, child_column, column_user_type_kw], ) .map_err(DbError::from_rusqlite)?; } insert_relationship_metadata( tx, &resolved_name, parent_table, parent_column, child_table, child_column, on_delete, on_update, )?; // Persistence runs inside the same tx so a write // failure rolls back both the schema and the metadata // (commit-db-last per ADR-0015 §6). let changes = Changes { schema_dirty: true, // The child table was rebuilt — its CSV needs to // be re-emitted from the new state too, in case // the FK column was newly created. rewritten_tables: vec![child_table.to_string()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; // Return the parent (1-side) description so the user sees // the new relationship via the inbound section — that's the // perspective that matches the `from .col to ...` // direction of the command. do_describe_table(conn, parent_table) } fn do_drop_relationship( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, selector: &RelationshipSelector, ) -> Result, DbError> { // Resolve to a single relationship row. let resolved: Option<(String, String, String, String, String)> = match selector { RelationshipSelector::Named { name } => conn .query_row( &format!( "SELECT name, parent_table, parent_column, child_table, child_column \ FROM {REL_TABLE} WHERE name = ?1;" ), [name], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .ok(), RelationshipSelector::Endpoints { parent_table, parent_column, child_table, child_column, } => conn .query_row( &format!( "SELECT name, parent_table, parent_column, child_table, child_column \ FROM {REL_TABLE} \ WHERE parent_table = ?1 AND parent_column = ?2 \ AND child_table = ?3 AND child_column = ?4;" ), [parent_table, parent_column, child_table, child_column], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .ok(), }; let (rel_name, parent_table, _parent_column, child_table, child_column) = resolved.ok_or_else(|| DbError::Sqlite { message: format!("no such relationship: {selector}"), kind: SqliteErrorKind::Other, })?; // Read child schema; build new schema without the FK. let old_schema = read_schema(conn, &child_table)?; let mut new_schema = old_schema.clone(); new_schema.foreign_keys.retain(|fk| fk.child_column != child_column); let child_table_for_persist = child_table.clone(); rebuild_table(conn, &child_table, &old_schema, &new_schema, |tx| { tx.execute( &format!("DELETE FROM {REL_TABLE} WHERE name = ?1;"), [rel_name.as_str()], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![child_table_for_persist.clone()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; // Show the parent (1-side) afterwards — same direction as // the command's `from to ...` reading. Ok(Some(do_describe_table(conn, &parent_table)?)) } /// `ALTER TABLE ADD [CONSTRAINT ] CHECK ()` (ADR-0035 /// §4g). A dry-run refuses the add if any existing row fails the /// predicate; the rebuild then re-emits the table with the new CHECK in /// its DDL and records it in `CHECK_TABLE`. A *named* CHECK on a pre-4g /// project (whose metadata table predates the `name` column — the /// rebuild-only migration) is refused with a friendly "rebuild first" /// message rather than a raw engine error. fn do_alter_add_table_check( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, name: Option<&str>, expr_sql: &str, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; if name.is_some() && !check_table_has_name_column(conn)? { return Err(DbError::Unsupported( "this project predates named constraints; run `rebuild` to \ upgrade it, then add the named constraint again." .to_string(), )); } // A named CHECK must not collide with an existing CHECK name on this // table NOR with a relationship name (FKs are also `DROP CONSTRAINT` // targets) — keeps `drop constraint ` unambiguous. if let Some(n) = name { let collides_check = old_schema .check_constraints .iter() .any(|c| c.name.as_deref() == Some(n)); let collides_rel: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"), [n], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if collides_check || collides_rel > 0 { return Err(DbError::Unsupported(format!( "a constraint named `{n}` already exists on `{table}`." ))); } } // Dry-run: a CHECK passes on TRUE or NULL; only FALSE fails, so // `WHERE NOT (expr)` counts the genuine violations. let violating: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM {tbl} WHERE NOT ({expr_sql});", tbl = quote_ident(table), ), [], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if violating > 0 { return Err(DbError::Unsupported(format!( "cannot add CHECK ({expr_sql}) to `{table}`: {violating} existing \ row(s) do not satisfy it." ))); } let mut new_schema = old_schema.clone(); new_schema.check_constraints.push(TableCheck { name: name.map(ToString::to_string), expr: expr_sql.to_string(), }); let table_owned = table.to_string(); let name_owned = name.map(ToString::to_string); let expr_owned = expr_sql.to_string(); rebuild_table(conn, table, &old_schema, &new_schema, |tx| { // MAX(seq)+1 avoids colliding with a gap a prior DROP left. let next_seq: i64 = tx .query_row( &format!( "SELECT COALESCE(MAX(seq), -1) + 1 FROM {CHECK_TABLE} \ WHERE table_name = ?1;" ), [table_owned.as_str()], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; tx.execute( &format!( "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \ VALUES (?1, ?2, ?3, ?4);" ), rusqlite::params![table_owned, next_seq, expr_owned, name_owned], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; do_describe_table(conn, table) } /// `ALTER TABLE ADD UNIQUE (, …)` (ADR-0035 §4g) — a composite /// UNIQUE constraint (anonymous: composite UNIQUE is PRAGMA-detected on /// read, ADR-0035 §4a.2, so it carries no name). A dry-run refuses the /// add if existing rows already contain a duplicate non-NULL tuple /// (NULLs are distinct under SQL's UNIQUE semantics). fn do_alter_add_unique( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, columns: &[String], ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; for c in columns { if !old_schema.columns.iter().any(|oc| &oc.name == c) { return Err(DbError::Sqlite { message: format!("no such column: {table}.{c}"), kind: SqliteErrorKind::NoSuchColumn, }); } } let non_null = columns .iter() .map(|c| format!("{} IS NOT NULL", quote_ident(c))) .collect::>() .join(" AND "); let group = columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let dup_groups: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM (SELECT 1 FROM {tbl} WHERE {non_null} \ GROUP BY {group} HAVING COUNT(*) > 1);", tbl = quote_ident(table), ), [], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if dup_groups > 0 { return Err(DbError::Unsupported(format!( "cannot add UNIQUE ({}) to `{table}`: existing rows contain \ duplicate values.", columns.join(", "), ))); } let mut new_schema = old_schema.clone(); new_schema.unique_constraints.push(columns.to_vec()); let table_owned = table.to_string(); rebuild_table(conn, table, &old_schema, &new_schema, |tx| { let changes = Changes { schema_dirty: true, rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; do_describe_table(conn, table) } /// `ALTER TABLE DROP CONSTRAINT ` (ADR-0035 §4g). Resolves /// `name` to a named table-level CHECK on `T` (rebuild without it + /// delete the metadata row), else to a named relationship (FK) whose /// child is `T` (via `do_drop_relationship`), else refuses. fn do_drop_constraint_by_name( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, name: &str, ) -> Result, DbError> { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); // 1. A named table-level CHECK on this table? if check_table_has_name_column(conn)? { let check_count: i64 = conn .query_row( &format!( "SELECT COUNT(*) FROM {CHECK_TABLE} \ WHERE table_name = ?1 AND name = ?2;" ), [table, name], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if check_count > 0 { let old_schema = read_schema(conn, table)?; let mut new_schema = old_schema.clone(); new_schema .check_constraints .retain(|c| c.name.as_deref() != Some(name)); let (t, n) = (table.to_string(), name.to_string()); rebuild_table(conn, table, &old_schema, &new_schema, |tx| { tx.execute( &format!( "DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;" ), [t.as_str(), n.as_str()], ) .map_err(DbError::from_rusqlite)?; let changes = Changes { schema_dirty: true, rewritten_tables: vec![t.clone()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); } } // 2. A named relationship (FK) whose child is this table? let rel_count: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1 AND child_table = ?2;"), [name, table], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if rel_count > 0 { return do_drop_relationship( conn, persistence, source, &RelationshipSelector::Named { name: name.to_string(), }, ); } // 3. A composite UNIQUE whose derived name (ADR-0035 Amendment 1, // `unique_`) matches? The constraint is anonymous in our // model, so we recompute each composite UNIQUE's name and match. // Order matters: a named CHECK/FK above shadows a derived UNIQUE // name (the distinctive `unique_` prefix makes a clash unlikely). let schema = read_schema(conn, table)?; let matched_cols: Vec> = schema .unique_constraints .iter() .filter(|cols| unique_constraint_name(cols) == name) .cloned() .collect(); if matched_cols.len() > 1 { // Two distinct UNIQUEs can derive the same name (e.g. a column // literally named `b_c` vs `UNIQUE (b, c)`). Refuse rather than // guess which to drop. return Err(DbError::Unsupported(format!( "the constraint name `{name}` is ambiguous on `{table}` — it \ matches more than one UNIQUE constraint; recreate the table \ to change them." ))); } if let Some(cols) = matched_cols.first() { let old_schema = schema.clone(); let mut new_schema = schema; new_schema.unique_constraints.retain(|c| c != cols); let table_owned = table.to_string(); rebuild_table(conn, table, &old_schema, &new_schema, |tx| { let changes = Changes { schema_dirty: true, rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; finalize_persistence(tx, persistence, source, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); } // 4. Not a known named constraint on this table. Err(DbError::Sqlite { message: format!("no such constraint: {name} on {table}"), kind: SqliteErrorKind::Other, }) } /// `ALTER TABLE ADD [CONSTRAINT ] FOREIGN KEY () /// REFERENCES

[(

)] [ON …]` (ADR-0035 §4g). Resolves a bare /// `REFERENCES

` to the parent's single-column PK, then delegates to /// `do_add_relationship` (the same machinery `add 1:n relationship` /// uses) with `create_fk = false` — the child column must already exist /// (an `ALTER … ADD FOREIGN KEY` references an existing column). fn do_alter_add_foreign_key( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, child_table: &str, name: Option<&str>, fk: &SqlForeignKey, ) -> Result { reject_internal_table_name(child_table)?; reject_internal_table_name(&fk.parent_table)?; let parent_column = match &fk.parent_column { Some(c) => c.clone(), None => { let ps = read_schema(conn, &fk.parent_table)?; if ps.primary_key.len() == 1 { ps.primary_key[0].clone() } else { return Err(DbError::Unsupported(format!( "`{parent}` has a composite primary key, so a bare reference \ is ambiguous — name the referenced column, e.g. \ `REFERENCES {parent}(

)`.", parent = fk.parent_table, ))); } } }; // The child column must already exist for `ALTER … ADD FOREIGN KEY` — // there is no SQL spelling to auto-create it (the `--create-fk` option // is the simple-mode `add relationship` surface only). Pre-check here // so the refusal speaks SQL, not the DSL flag (ADR-0035 Amendment 1, // gap C). A missing child *table* is left to `do_add_relationship`'s // own "no such table". if let Ok(child_schema) = read_schema(conn, child_table) && child_schema.columns.iter().all(|c| c.name != fk.child_column) { return Err(DbError::Unsupported(format!( "column `{child_table}.{child}` does not exist — add it first \ (`alter table {child_table} add column {child} `), then \ add the foreign key.", child = fk.child_column, ))); } do_add_relationship( conn, persistence, source, name, &fk.parent_table, &parent_column, child_table, &fk.child_column, fk.on_delete, fk.on_update, false, ) } /// Create an index on `table` over `columns` (ADR-0025). /// /// Refuses a redundant index on an already-indexed column set /// and a name collision. The index name is auto-generated as /// `
__idx` when not supplied. /// Resolve an index name: the user-given name, or the ADR-0025 /// auto-name `
__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, ) } /// Refuse an internal `__rdbms_*` table as "no such table" — the same /// opacity the rest of the app presents (internal tables are filtered /// from `list_tables` and never offered in completion). Guards the /// user-facing schema-mutation executors so a deliberately-typed /// internal name cannot index or alter the metadata tables (ADR-0035 /// §4d/§4e; the grammar's `reject_internal_table` covers only the typed /// SQL family, not the simple DSL nodes). fn reject_internal_table_name(table: &str) -> Result<(), DbError> { if table.to_ascii_lowercase().starts_with("__rdbms_") { return Err(DbError::Sqlite { message: format!("no such table: {table}"), kind: SqliteErrorKind::NoSuchTable, }); } Ok(()) } /// 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 { 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>, source: Option<&str>, name: Option<&str>, table: &str, columns: &[String], unique: bool, ) -> Result { // 0. Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table) — both the simple `add index` and SQL // `CREATE INDEX` surfaces reach here, and the auto-index name embeds // the table name, so it must use the stored case. let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); // 1. Table must exist; gather its columns. let schema = read_schema(conn, table)?; // 2. Every indexed column must exist on the table. for col in columns { if !schema.columns.iter().any(|c| &c.name == col) { return Err(DbError::Sqlite { message: format!("no such column: {table}.{col}"), kind: SqliteErrorKind::NoSuchColumn, }); } } // 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 && i.unique == unique) { return Err(DbError::Unsupported(format!( "the columns ({}) of `{table}` are already indexed by `{}`.", columns.join(", "), dup.name, ))); } // 4. Resolve the index name (auto-generate when omitted). let resolved = resolve_index_name(name, table, columns); // 5. Refuse a name collision. let name_taken: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master \ WHERE type = 'index' AND name = ?1;", [&resolved], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if name_taken > 0 { return Err(DbError::Unsupported(format!( "an index named `{resolved}` already exists. \ Pick a different name or drop the existing one first." ))); } // 6. Create the index and persist. let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let cols_csv = columns .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let unique_kw = if unique { "UNIQUE " } else { "" }; let ddl = format!( "CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});", idx = quote_ident(&resolved), tbl = quote_ident(table), cols = cols_csv, ); 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 { schema_dirty: true, ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } /// Drop an index identified by name or by table + column set /// (ADR-0025). Returns the affected table's description. fn do_drop_index( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, selector: &IndexSelector, ) -> Result { let (index_name, table_name) = match selector { IndexSelector::Named { name } => { let lookup = conn.query_row( "SELECT tbl_name FROM sqlite_master \ WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;", [name], |row| row.get::<_, String>(0), ); match lookup { Ok(table) => (name.clone(), table), Err(rusqlite::Error::QueryReturnedNoRows) => { return Err(DbError::Sqlite { message: format!("no such index: {name}"), kind: SqliteErrorKind::Other, }); } Err(e) => return Err(DbError::from_rusqlite(e)), } } IndexSelector::Columns { table, columns } => { // Surface a missing table as such, not as "no index". read_schema(conn, table)?; let matches: Vec = read_table_indexes(conn, table)? .into_iter() .filter(|i| i.columns.as_slice() == columns.as_slice()) .collect(); match matches.as_slice() { [] => { return Err(DbError::Sqlite { message: format!( "no index on {table} ({}) exists", columns.join(", ") ), kind: SqliteErrorKind::Other, }); } [one] => (one.name.clone(), table.clone()), many => { let names = many .iter() .map(|i| format!("`{}`", i.name)) .collect::>() .join(", "); return Err(DbError::Unsupported(format!( "more than one index on {table} ({}) matches \ ({names}); drop it by name instead.", columns.join(", ") ))); } } } }; let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; tx.execute_batch(&format!( "DROP INDEX {ident};", ident = quote_ident(&index_name) )) .map_err(DbError::from_rusqlite)?; let description = do_describe_table(conn, &table_name)?; let changes = Changes { schema_dirty: true, ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } /// Read-only wrapper around `do_describe_table` that runs an /// auxiliary `history.log` append for user-issued /// `show table` commands. fn do_describe_table_request( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, name: &str, ) -> Result { let description = do_describe_table(conn, name)?; if let (Some(p), Some(text)) = (persistence, source) { p.append_history(text) .map_err(DbError::from_persistence)?; } Ok(description) } fn do_describe_table(conn: &Connection, name: &str) -> Result { // Column info — including the ADR-0029 constraints — comes // from `read_schema`, the single source of per-column truth // (it joins `pragma_table_info` with our type metadata and // detects single-column UNIQUE via `pragma_index_list`). A // missing table surfaces as `read_schema`'s NoSuchTable. let schema = read_schema(conn, name)?; let columns: Vec = schema .columns .iter() .map(|c| ColumnDescription { name: c.name.clone(), user_type: c.user_type, sqlite_type: c.sqlite_type.clone(), notnull: c.notnull, primary_key: c.primary_key, unique: c.unique, default: c.default_sql.clone(), check: c.check.clone(), }) .collect(); let outbound_relationships = read_relationships_outbound(conn, name)?; let inbound_relationships = read_relationships_inbound(conn, name)?; let indexes = read_table_indexes(conn, name)?; Ok(TableDescription { name: name.to_string(), columns, outbound_relationships, inbound_relationships, indexes, unique_constraints: schema.unique_constraints.clone(), check_constraints: schema.check_constraints, }) } // --- Data operations (C5) ----------------------------------- fn impl_value_for( schema: &ReadSchema, column: &str, value: &Value, ) -> Result { let col = schema .columns .iter() .find(|c| c.name == column) .ok_or_else(|| DbError::Sqlite { message: format!("no such column: {column}"), kind: SqliteErrorKind::NoSuchColumn, })?; let ty = col.user_type.ok_or_else(|| { DbError::Unsupported(format!( "column `{column}` has no user-type metadata; cannot validate value" )) })?; value .bind_for_column(column, ty) .map_err(|e: ValueError| DbError::InvalidValue(e.to_string())) } fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value { use rusqlite::types::Value as V; match b { Bound::Integer(i) => V::Integer(*i), Bound::Real(r) => V::Real(*r), Bound::Text(s) => V::Text(s.clone()), Bound::Null => V::Null, } } // ================================================================= // WHERE-expression → parameterised SQL (ADR-0026 §6) // ================================================================= /// Compile an `Expr` to a parameterised SQL boolean expression. /// /// Every literal becomes a `?` placeholder pushed onto `params` /// (1-based, continuing from the current `params.len()` — so /// the caller can pre-load SET-clause params); identifiers are /// `quote_ident`-quoted. The raw user text is never spliced /// into the SQL. Connectives, `NOT`, and parentheses come from /// the tree structure; the database re-derives precedence from /// the emitted operators. fn compile_expr( expr: &Expr, schema: &ReadSchema, params: &mut Vec, ) -> String { match expr { Expr::Or(terms) => join_expr(terms, "OR", schema, params), Expr::And(terms) => join_expr(terms, "AND", schema, params), Expr::Not(inner) => { format!("(NOT {})", compile_expr(inner, schema, params)) } Expr::Predicate(predicate) => compile_predicate(predicate, schema, params), } } fn join_expr( terms: &[Expr], op: &str, schema: &ReadSchema, params: &mut Vec, ) -> String { let parts: Vec = terms .iter() .map(|t| compile_expr(t, schema, params)) .collect(); format!("({})", parts.join(&format!(" {op} "))) } fn compile_predicate( predicate: &Predicate, schema: &ReadSchema, params: &mut Vec, ) -> String { match predicate { Predicate::Compare { left, op, right } => { // A literal on one side binds against the column on // the other side, where there is one. let left_ty = operand_column_type(left, schema); let right_ty = operand_column_type(right, schema); let lhs = compile_operand(left, right_ty, params); let rhs = compile_operand(right, left_ty, params); format!("{lhs} {} {rhs}", compare_op_sql(*op)) } Predicate::Like { target, pattern, negated, } => { let t = compile_operand(target, None, params); let p = compile_operand(pattern, None, params); let not = if *negated { "NOT " } else { "" }; format!("{t} {not}LIKE {p}") } Predicate::Between { target, low, high, negated, } => { let ty = operand_column_type(target, schema); let t = compile_operand(target, None, params); let lo = compile_operand(low, ty, params); let hi = compile_operand(high, ty, params); let not = if *negated { "NOT " } else { "" }; format!("{t} {not}BETWEEN {lo} AND {hi}") } Predicate::In { target, items, negated, } => { let ty = operand_column_type(target, schema); let t = compile_operand(target, None, params); let rendered: Vec = items .iter() .map(|item| compile_operand(item, ty, params)) .collect(); let not = if *negated { "NOT " } else { "" }; format!("{t} {not}IN ({})", rendered.join(", ")) } Predicate::IsNull { target, negated } => { let t = compile_operand(target, None, params); let not = if *negated { "NOT " } else { "" }; format!("{t} IS {not}NULL") } } } /// Render an operand. A column becomes a quoted identifier; a /// literal becomes a `?` placeholder bound against `against`'s /// type when one is supplied. fn compile_operand( operand: &Operand, against: Option, params: &mut Vec, ) -> String { match operand { Operand::Column { name, .. } => quote_ident(name), Operand::Literal { value, .. } => { params.push(bind_where_literal(value, against)); format!("?{}", params.len()) } } } /// The user-facing type of a column operand, if the operand is /// a column the schema knows. Literals and unknown columns /// yield `None`. fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option { match operand { Operand::Column { name, .. } => schema .columns .iter() .find(|c| c.name.eq_ignore_ascii_case(name)) .and_then(|c| c.user_type), Operand::Literal { .. } => None, } } /// Bind a WHERE-clause literal. When a target column type is /// known and the literal converts cleanly, bind through it; /// otherwise bind by the literal's own syntactic shape — a /// type-mismatched comparison is flagged in the editor but /// still runs (ADR-0026 §7). fn bind_where_literal(value: &Value, against: Option) -> rusqlite::types::Value { if let Some(ty) = against && let Ok(bound) = value.bind_for_column("", ty) { return bound_to_sqlite_value(&bound); } bound_to_sqlite_value(&syntactic_bound(value)) } /// Bind a literal by the shape it was written in, ignoring any /// column type — the permissive fallback for type-mismatched /// comparisons and the literal-vs-literal case. fn syntactic_bound(value: &Value) -> Bound { match value { Value::Null => Bound::Null, Value::Bool(b) => Bound::Integer(i64::from(*b)), Value::Text(s) => Bound::Text(s.clone()), Value::Number(s) => s.parse::().map_or_else( |_| { s.parse::() .map_or_else(|_| Bound::Text(s.clone()), Bound::Real) }, Bound::Integer, ), } } const fn compare_op_sql(op: CompareOp) -> &'static str { match op { CompareOp::Eq => "=", // `<>` is standard SQL for inequality (ADR-0026 §6). CompareOp::NotEq => "<>", CompareOp::Lt => "<", CompareOp::LtEq => "<=", CompareOp::Gt => ">", CompareOp::GtEq => ">=", } } /// Execute an INSERT/UPDATE/DELETE and convert any rusqlite /// failure into a `DbError`. Wraps the raw `conn.execute` so the /// three callers (insert, update, delete) have a single hook for /// future row-pinpointing per ADR-0019 §6 — when re-query lands, /// the runtime-side wiring will pass the table identity into the /// translator from here. /// /// Today this is a thin wrapper. The `table` argument is /// retained as the future re-query hook needs it. (The previous /// `enrich_fk_message` helper that lived here, listing all the /// outbound/inbound FKs in the error message, was absorbed into /// the friendly-error layer's catalog wording per ADR-0019; /// re-introducing the per-FK detail belongs to the re-query /// follow-on, not as freeform plain-text appended to engine /// errors.) fn execute_with_fk_enrichment( conn: &Connection, _table: &str, sql: &str, params: &[rusqlite::types::Value], ) -> Result { conn.execute(sql, rusqlite::params_from_iter(params.iter())) .map_err(DbError::from_rusqlite) } /// Fetch a small `DataResult` containing only the rows whose /// rowids appear in `rowids`. Used so writes can show only the /// rows they touched rather than the whole table. fn query_rows_by_rowid( conn: &Connection, table: &str, rowids: &[i64], ) -> Result { let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); if rowids.is_empty() { return Ok(DataResult { table_name: table.to_string(), columns: column_names, column_types, rows: Vec::new(), }); } let cols_csv = column_names .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let placeholders = (1..=rowids.len()) .map(|i| format!("?{i}")) .collect::>() .join(", "); let sql = format!( "SELECT {cols} FROM {ident} WHERE rowid IN ({placeholders});", cols = cols_csv, ident = quote_ident(table), ); let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let params: Vec = rowids .iter() .map(|id| rusqlite::types::Value::Integer(*id)) .collect(); let rows_iter = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { let mut cells: Vec = Vec::with_capacity(column_names.len()); for i in 0..column_names.len() { cells.push(row.get(i)?); } Ok(cells) }) .map_err(DbError::from_rusqlite)?; let mut rows: Vec>> = Vec::new(); for r in rows_iter { let cells = r.map_err(DbError::from_rusqlite)?; let formatted: Vec> = cells .into_iter() .zip(column_types.iter()) .map(|(v, ty)| format_cell(v, *ty)) .collect(); rows.push(formatted); } Ok(DataResult { table_name: table.to_string(), columns: column_names, column_types, rows, }) } fn count_rows(conn: &Connection, table: &str) -> Result { let sql = format!("SELECT COUNT(*) FROM {ident};", ident = quote_ident(table)); conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) .map_err(DbError::from_rusqlite) } fn do_insert( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, user_columns: Option<&[String]>, user_values: &[Value], ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; // Resolve which columns the user is providing values for. let user_cols: Vec = match user_columns { Some(cols) => cols.to_vec(), None => { // Short form: every non-auto-generated column in // schema declaration order. Serial and shortid both // get auto-filled below. schema .columns .iter() .filter(|c| !matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId))) .map(|c| c.name.clone()) .collect() } }; if user_cols.len() != user_values.len() { return Err(DbError::InvalidValue(format!( "expected {} value(s) for ({}), got {}", user_cols.len(), user_cols.join(", "), user_values.len() ))); } let mut bindings: Vec<(String, Bound)> = Vec::with_capacity(user_cols.len()); for (col_name, value) in user_cols.iter().zip(user_values.iter()) { let bound = impl_value_for(&schema, col_name, value)?; bindings.push((col_name.clone(), bound)); } // Auto-fill any shortid columns the user didn't list. let provided: std::collections::HashSet = bindings.iter().map(|(c, _)| c.clone()).collect(); for c in &schema.columns { if c.user_type == Some(Type::ShortId) && !provided.contains(&c.name) { bindings.push((c.name.clone(), Bound::Text(shortid::generate()))); } } // Auto-fill any non-PK serial columns the user didn't list // (ADR-0018 §5). PK serial columns rely on SQLite's rowid // alias (omitting the column from the INSERT yields the // next rowid for free); non-PK serial needs explicit // MAX(col)+1 application-side because there's no engine- // level auto-increment for non-PK columns. The worker- // thread serialisation (ADR-0010) makes this read-then- // write sequence safe without explicit locking. for c in &schema.columns { if c.user_type == Some(Type::Serial) && !c.primary_key && !provided.contains(&c.name) { let next: i64 = conn .query_row( &format!( "SELECT COALESCE(MAX({col}), 0) + 1 FROM {tbl};", col = quote_ident(&c.name), tbl = quote_ident(table), ), [], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; bindings.push((c.name.clone(), Bound::Integer(next))); } } if bindings.is_empty() { return Err(DbError::InvalidValue( "INSERT requires at least one column value".to_string(), )); } let cols_csv = bindings .iter() .map(|(c, _)| quote_ident(c)) .collect::>() .join(", "); let placeholders = (1..=bindings.len()) .map(|i| format!("?{i}")) .collect::>() .join(", "); let sql = format!( "INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});", ident = quote_ident(table), ); debug!(sql = %sql, "insert"); let params: Vec = bindings.iter().map(|(_, b)| bound_to_sqlite_value(b)).collect(); let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; let new_rowid = conn.last_insert_rowid(); let data = query_rows_by_rowid(conn, table, &[new_rowid])?; let changes = Changes { schema_dirty: false, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, data, }) } /// Build the parameterised `UPDATE … SET … WHERE …` statement. /// Separated from `do_update` so the `explain` path runs /// `EXPLAIN QUERY PLAN` against the exact same statement /// (ADR-0028 §2). `compile_expr` continues the `?N` numbering /// from the SET-clause params already pushed. fn build_update_sql( schema: &ReadSchema, table: &str, assignments: &[(String, Value)], filter: &RowFilter, ) -> Result<(String, Vec), DbError> { let mut params: Vec = Vec::new(); let mut set_clauses: Vec = Vec::with_capacity(assignments.len()); for (col, value) in assignments { let bound = impl_value_for(schema, col, value)?; set_clauses.push(format!( "{col_id} = ?{n}", col_id = quote_ident(col), n = params.len() + 1 )); params.push(bound_to_sqlite_value(&bound)); } let where_sql = match filter { RowFilter::AllRows => String::new(), RowFilter::Where(expr) => { format!(" WHERE {}", compile_expr(expr, schema, &mut params)) } }; let sql = format!( "UPDATE {ident} SET {sets}{where_sql};", ident = quote_ident(table), sets = set_clauses.join(", "), ); Ok((sql, params)) } fn do_update( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, assignments: &[(String, Value)], filter: &RowFilter, ) -> Result { if assignments.is_empty() { return Err(DbError::InvalidValue( "UPDATE requires at least one assignment".to_string(), )); } let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; // Capture rowids of matching rows up front so we can fetch // the updated rows even if the UPDATE changed the WHERE column. let rowids = match filter { RowFilter::AllRows => select_all_rowids(conn, table)?, RowFilter::Where(expr) => { let mut where_params: Vec = Vec::new(); let clause = compile_expr(expr, &schema, &mut where_params); let mut stmt = conn .prepare(&format!( "SELECT rowid FROM {ident} WHERE {clause};", ident = quote_ident(table), )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map(rusqlite::params_from_iter(where_params.iter()), |row| { row.get::<_, i64>(0) }) .map_err(DbError::from_rusqlite)?; let mut ids = Vec::new(); for r in rows { ids.push(r.map_err(DbError::from_rusqlite)?); } ids } }; let (sql, params) = build_update_sql(&schema, table, assignments, filter)?; debug!(sql = %sql, "update"); let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; let data = query_rows_by_rowid(conn, table, &rowids)?; let changes = Changes { schema_dirty: false, rewritten_tables: vec![table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, data, }) } fn select_all_rowids(conn: &Connection, table: &str) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT rowid FROM {ident};", ident = quote_ident(table) )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, i64>(0)) .map_err(DbError::from_rusqlite)?; let mut ids = Vec::new(); for r in rows { ids.push(r.map_err(DbError::from_rusqlite)?); } Ok(ids) } /// Build the parameterised `DELETE FROM … WHERE …` statement. /// Separated from `do_delete` so the `explain` path runs /// `EXPLAIN QUERY PLAN` against the exact same statement /// (ADR-0028 §2). fn build_delete_sql( schema: &ReadSchema, table: &str, filter: &RowFilter, ) -> (String, Vec) { let mut params: Vec = Vec::new(); let where_sql = match filter { RowFilter::AllRows => String::new(), RowFilter::Where(expr) => { format!(" WHERE {}", compile_expr(expr, schema, &mut params)) } }; let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table)); (sql, params) } fn do_delete( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, filter: &RowFilter, ) -> Result { let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; // Snapshot child-table row counts before the delete so we // can detect cascade effects via diffing afterwards. ON // UPDATE CASCADE does not change row counts and so is not // detected here — it would need value-level diffing, which // a future iteration can add. let inbound = read_relationships_inbound(conn, table)?; let mut before_counts: Vec<(String, i64)> = Vec::with_capacity(inbound.len()); for r in &inbound { before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?)); } let (sql, params) = build_delete_sql(&schema, table, filter); debug!(sql = %sql, "delete"); let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; // Compare child-table counts after the delete; non-zero // diffs are cascade effects. We also collect cascaded // tables so the persistence phase rewrites their CSVs too. let mut cascade: Vec = Vec::new(); let mut rewritten_tables: Vec = vec![table.to_string()]; for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) { let after_count = count_rows(conn, &rel.other_table)?; let mut rows_changed = before_count - after_count; // A self-referential FK (child == target): the before/after // diff also covers the directly-deleted rows, which are // already reported in `rows_affected` and are not cascade // effects. Subtract them so the summary reports only the rows // removed *via* the self-reference. Shared with `do_sql_delete`. let self_referential = rel.other_table == table; if self_referential { rows_changed -= rows_affected as i64; } if rows_changed > 0 { cascade.push(CascadeEffect { relationship_name: rel.name.clone(), child_table: rel.other_table.clone(), rows_changed, action: rel.on_delete, }); // The target's CSV is already queued; only add a distinct // child table (a self-ref child is the target itself). if !self_referential { rewritten_tables.push(rel.other_table.clone()); } } } let changes = Changes { schema_dirty: false, rewritten_tables, ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { rows_affected, cascade, // The DSL `delete` has no RETURNING (a SQL-only clause); the // empty result is skipped by the renderer (ADR-0033 §5, 3g). data: DataResult { table_name: table.to_string(), columns: Vec::new(), column_types: Vec::new(), rows: Vec::new(), }, }) } /// Read-only wrapper that adds the `history.log` append for /// `show data` user commands. fn do_query_data_request( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, table: &str, filter: Option<&Expr>, limit: Option, ) -> Result { let data = do_query_data(conn, table, filter, limit)?; if let (Some(p), Some(text)) = (persistence, source) { p.append_history(text) .map_err(DbError::from_persistence)?; } Ok(data) } /// Worker handler for `Request::RunSelect` (ADR-0030 §6, /// ADR-0031). Mirrors `do_query_data_request`: run the /// statement, append the literal line to `history.log` so a /// replay re-runs it (ADR-0030 §11). fn do_run_select_request( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, sql: &str, ) -> Result { let data = do_run_select(conn, sql)?; if let (Some(p), Some(text)) = (persistence, source) { p.append_history(text) .map_err(DbError::from_persistence)?; } Ok(data) } /// Currently-stored non-NULL values of one column, for shortid /// collision-avoidance (passed to `generate_shortid_batch`). fn existing_shortids( conn: &Connection, table: &str, column: &str, ) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;", col = quote_ident(column), tbl = quote_ident(table), )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for r in rows { out.push(r.map_err(DbError::from_rusqlite)?); } Ok(out) } /// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6, /// sub-phase 3d). /// /// Returns the SQL the worker should execute plus its bound /// params. When the user's `(column_list)` omits one or more /// `shortid` columns, this materialises the row source (Option B: /// run it as a query), synthesises a fresh distinct id per row via /// `generate_shortid_batch`, and reconstructs a parameterised /// multi-row `INSERT` over the listed columns plus the omitted /// shortid columns. Otherwise it returns the original `sql` /// verbatim with no params (the 3b path): /// /// - no explicit column list → the row source supplies every /// column positionally (a listed shortid is the user's value); /// - no omitted shortid column → nothing to fill; /// - the row source yields zero rows → nothing to fill (the /// verbatim INSERT inserts nothing without a NOT-NULL violation). /// /// `serial` columns are not handled here — an omitted `serial` /// primary key is filled by the engine's rowid (ADR-0033 §6). fn plan_shortid_autofill( conn: &Connection, target_table: &str, sql: &str, listed_columns: &[String], row_source: &str, trailing_tail: &str, ) -> Result<(String, Vec), DbError> { if listed_columns.is_empty() { return Ok((sql.to_string(), Vec::new())); } let schema = read_schema(conn, target_table)?; // Identifiers are case-preserving but matched case-insensitively // (ADR-0009): a shortid column counts as omitted unless the user // listed a name equal to it ignoring ASCII case. let listed_ci: Vec = listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect(); let omitted_shortids: Vec = schema .columns .iter() .filter(|c| c.user_type == Some(Type::ShortId)) .filter(|c| !listed_ci.contains(&c.name.to_ascii_lowercase())) .map(|c| c.name.clone()) .collect(); if omitted_shortids.is_empty() { return Ok((sql.to_string(), Vec::new())); } // Materialise the row source (VALUES / SELECT / WITH … SELECT) // as concrete rows for the listed columns. let listed_count = listed_columns.len(); let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?; // Arity guard: if the row source's column count disagrees with // the user's column list, do NOT auto-fill — reading // `listed_count` cells would silently drop extra columns (or // error opaquely on too few). Defer to the verbatim statement // so the engine reports the mismatch as it does on the // non-auto-fill path (a friendly pre-flight lands in 3i). if stmt.column_count() != listed_count { return Ok((sql.to_string(), Vec::new())); } let mut rows: Vec> = Vec::new(); { let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?; while let Some(r) = q.next().map_err(DbError::from_rusqlite)? { let mut cells = Vec::with_capacity(listed_count); for i in 0..listed_count { cells.push( r.get::<_, rusqlite::types::Value>(i) .map_err(DbError::from_rusqlite)?, ); } rows.push(cells); } } let n = rows.len(); if n == 0 { // Nothing to insert — the verbatim statement inserts zero // rows without touching the omitted shortid column. return Ok((sql.to_string(), Vec::new())); } // A fresh, distinct shortid per row for each omitted column, // avoiding collision with values already stored in that column. let mut id_batches: Vec> = Vec::with_capacity(omitted_shortids.len()); for col in &omitted_shortids { let existing = existing_shortids(conn, target_table, col)?; id_batches.push(generate_shortid_batch(n, &existing)?); } // Reconstruct: listed columns followed by the omitted shortid // columns; one parameterised tuple per materialised row. let all_cols: Vec<&String> = listed_columns.iter().chain(omitted_shortids.iter()).collect(); let cols_csv = all_cols .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); let per_tuple = all_cols.len(); let mut params: Vec = Vec::with_capacity(n * per_tuple); let mut tuples: Vec = Vec::with_capacity(n); let mut ph = 1; for (row_idx, row) in rows.into_iter().enumerate() { for cell in row { params.push(cell); } for batch in &id_batches { params.push(batch[row_idx].clone()); } let placeholders = (0..per_tuple) .map(|_| { let s = format!("?{ph}"); ph += 1; s }) .collect::>() .join(", "); tuples.push(format!("({placeholders})")); } // Preserve any trailing clause — `ON CONFLICT …` (3h) and/or // `RETURNING …` (3g). The reconstruction rebuilds only INSERT … // VALUES …, so without this the UPSERT action would be lost and // `RETURNING *` would yield no rows. `trailing_tail` is "" when // the statement has neither. let trailing_suffix = if trailing_tail.is_empty() { String::new() } else { format!(" {trailing_tail}") }; let exec_sql = format!( "INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{trailing_suffix};", tbl = quote_ident(target_table), vals = tuples.join(", "), ); Ok((exec_sql, params)) } /// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1, /// sub-phase 3b). Mirrors `do_insert`'s persistence discipline: /// run the validated SQL inside a transaction, re-persist the /// target table's CSV + append `history.log` via /// `finalize_persistence` *before* `tx.commit()` (so a /// persistence failure rolls the insert back), then commit. /// /// Grammar-as-text (ADR-0030 §4): normally the values are literals /// in `sql` and no parameters are bound. The sub-phase 3d shortid /// auto-fill path is the exception — it reconstructs a /// parameterised `INSERT` (see `plan_shortid_autofill`); either /// way `history.log` records the original `source`, never the /// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL /// engine errors surface enriched via `execute_with_fk_enrichment` /// + the friendly-error layer. /// /// Auto-show is best-effort: the inserted rows are the last /// `rows_affected` rowids ending at `last_insert_rowid()`. For the /// common case (sequential / engine-assigned rowids) this is /// exactly the inserted rows; an INSERT that sets explicit /// non-contiguous rowid/INTEGER-PK values may surface a partial /// view. `RETURNING` (sub-phase 3g) is the precise tool. #[allow(clippy::too_many_arguments)] fn do_sql_insert( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, sql: &str, target_table: &str, listed_columns: &[String], row_source: &str, returning: bool, literal_rows: &[Vec>], ) -> Result { debug!(sql = %sql, table = %target_table, returning, "sql_insert"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); // ADR-0036 Phase 1: validate captured literal VALUES against their // column types BEFORE the (still verbatim) insert runs — sharing the // DSL's per-type validators (`impl_value_for`) for identical wording. // Only literal positions are checked; expression positions (`None`) // are left to the engine. Column mapping mirrors the engine's: an // explicit column list maps by position; natural order maps to the // schema's columns in definition order. An out-of-range position // (arity mismatch) is left for the engine / parse-time diagnostic. // Execution below is unchanged (no binding, no auto-fill change). if literal_rows.iter().any(|r| r.iter().any(Option::is_some)) { let schema = read_schema(conn, target_table)?; let columns: Vec<&str> = if listed_columns.is_empty() { schema.columns.iter().map(|c| c.name.as_str()).collect() } else { listed_columns.iter().map(String::as_str).collect() }; for row in literal_rows { for (idx, slot) in row.iter().enumerate() { if let Some(value) = slot && let Some(col) = columns.get(idx) { impl_value_for(&schema, col, value)?; } } } } // The `shortid` auto-fill rewrite reconstructs only `INSERT … // VALUES …` and would drop any trailing clause — `ON CONFLICT …` // (3h) and/or `RETURNING …` (3g). `row_source` is the clean // VALUES/SELECT text (`build_sql_insert` stops the slice at the // first trailing clause), so whatever follows it in the full // `sql` is exactly that tail; extract it here so the rewrite can // re-append it verbatim. On the verbatim (no auto-fill) path the // original `sql` already carries the tail, so it is consumed only // by the rewrite. let trailing_tail: String = if row_source.is_empty() { String::new() } else { sql.find(row_source) .map(|i| sql[i + row_source.len()..].trim().trim_end_matches(';').trim().to_string()) .unwrap_or_default() }; // Sub-phase 3d: when the user's column list omits one or more // `shortid` columns, the worker materialises the row source, // synthesises fresh distinct ids, and reinserts the augmented // rows. Returns the executable SQL + bound params; an empty // params vec with the original `sql` means "no auto-fill — // execute verbatim" (the 3b path). let (exec_sql, params) = plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?; let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; // RETURNING (3g): one pass inserts and yields the inserted rows // (incl. any auto-filled shortid), so the returned set is the // precise auto-show and rows_affected is its length. Without // RETURNING, fall back to the best-effort rowid auto-show. let (rows_affected, data) = if returning { let data = run_returning(conn, &exec_sql, ¶ms, target_table)?; (data.rows.len(), data) } else { let n = execute_with_fk_enrichment(conn, target_table, &exec_sql, ¶ms)?; let last = conn.last_insert_rowid(); let rowids: Vec = if n == 0 { Vec::new() } else { let count = n as i64; ((last - count + 1)..=last).collect() }; let data = query_rows_by_rowid(conn, target_table, &rowids)?; (n, data) }; let changes = Changes { schema_dirty: false, rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, data, }) } /// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2, /// sub-phase 3e). Mirrors `do_sql_insert`'s persistence /// discipline: run the validated SQL inside a transaction, /// re-persist the target table's CSV + append `history.log` via /// `finalize_persistence` *before* `tx.commit()`, then commit. /// /// Grammar-as-text (ADR-0030 §4): the assignment and predicate /// values are literals in `sql`, so no parameters are bound. A SQL /// `UPDATE` without `WHERE` runs across all rows as written /// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero /// rows is a success (`rows_affected == 0`); the persistence /// write-through still runs (re-persisting the unchanged CSV is a /// no-op-equivalent and keeps the path uniform). /// /// Auto-show: 3e returns an empty [`DataResult`] — the affected /// rows can't be shown precisely without `RETURNING` (sub-phase /// 3g, which is the precise tool). The summary surfaces the /// affected-row count; the renderer skips the (column-less) table. fn do_sql_update( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, sql: &str, target_table: &str, returning: bool, set_literals: &[(String, Option)], ) -> Result { debug!(sql = %sql, table = %target_table, returning, "sql_update"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); // ADR-0036 Phase 2: validate each captured `SET col = ` // against its column type BEFORE the (still verbatim) update runs — // sharing the DSL's per-type validators (`impl_value_for`, the same // helper `build_update_sql` uses) for identical wording. Only literal // assignments are checked; expression positions (`None`) and the // `WHERE` predicate are left to the engine (ADR-0036 §2). Execution // below is unchanged (no binding). if set_literals.iter().any(|(_, v)| v.is_some()) { let schema = read_schema(conn, target_table)?; for (col, slot) in set_literals { if let Some(value) = slot { impl_value_for(&schema, col, value)?; } } } let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; // RETURNING (3g): one pass performs the update and yields the // modified rows; rows_affected is the row count. Without // RETURNING the affected-row count surfaces and the (column-less) // DataResult is skipped by the renderer (3e behaviour). let (rows_affected, data) = if returning { let data = run_returning(conn, sql, &[], target_table)?; (data.rows.len(), data) } else { let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?; ( n, DataResult { table_name: target_table.to_string(), columns: Vec::new(), column_types: Vec::new(), rows: Vec::new(), }, ) }; let changes = Changes { schema_dirty: false, rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, data }) } /// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7, /// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing /// only in that it executes the verbatim grammar-validated `sql` /// rather than building the statement from a typed filter. /// /// Cascade detection (ADR-0033 Amendment 2): the worker snapshots /// each inbound child table's row count *before* the DELETE, runs /// the statement inside a transaction (the engine applies any /// `ON DELETE CASCADE`), counts the children again *after*, and /// reports the positive difference as a [`CascadeEffect`]. This is /// the identical mechanism the DSL path uses, so the SQL and DSL /// DELETE produce the same per-relationship summary on the same /// schema/data — and since both return a [`DeleteResult`] routed /// through `CommandOutcome::Delete`, the render-layer formatter is /// shared with no duplication. `ON DELETE SET NULL` leaves row /// counts unchanged and so is not reported on either path (a /// deferred enhancement for both). /// /// Because the diff observes the result of executing the whole /// statement, the WHERE clause is never inspected — a WHERE that /// itself contains a subquery (the R2 invariant) is correct by /// construction and carries no extra per-child query cost. /// /// Persistence discipline matches `do_delete` / `do_sql_update`: /// re-persist the target's CSV *and every cascade-affected child's* /// CSV via `finalize_persistence` (which also appends `source` to /// `history.log`) *before* `tx.commit()`, so a persistence failure /// rolls the delete back. A DELETE matching zero rows is a success /// (`rows_affected == 0`, empty cascade); the target's CSV is still /// re-persisted, keeping the path uniform. fn do_sql_delete( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, sql: &str, target_table: &str, returning: bool, ) -> Result { debug!(sql = %sql, table = %target_table, returning, "sql_delete"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); // Snapshot child-table row counts before the delete so cascade // effects can be detected by diffing afterwards (Amendment 2; // identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET // NULL do not change row counts and so are not detected here. let inbound = read_relationships_inbound(conn, target_table)?; let mut before_counts: Vec = Vec::with_capacity(inbound.len()); for r in &inbound { before_counts.push(count_rows(conn, &r.other_table)?); } let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; // RETURNING (3g): one pass deletes and yields the rows as they // were *before* deletion. `rows_affected` is the count of // directly-deleted rows either way (RETURNING does not yield // cascade-deleted child rows, so data.rows.len() == direct // deletes), which keeps the self-ref cascade correction below // valid. The cascade pre-count was already captured above. let (rows_affected, data) = if returning { let data = run_returning(conn, sql, &[], target_table)?; (data.rows.len(), data) } else { let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?; ( n, DataResult { table_name: target_table.to_string(), columns: Vec::new(), column_types: Vec::new(), rows: Vec::new(), }, ) }; // Compare child-table counts after the delete; positive diffs // are cascade effects. Collect the cascaded tables so the // persistence phase rewrites their CSVs too. let mut cascade: Vec = Vec::new(); let mut rewritten_tables: Vec = vec![target_table.to_string()]; for (rel, before_count) in inbound.iter().zip(before_counts.iter()) { let after_count = count_rows(conn, &rel.other_table)?; let mut rows_changed = before_count - after_count; // A self-referential FK (child == target): the before/after // diff also covers the directly-deleted rows, which are // already reported in `rows_affected` and are not cascade // effects. Subtract them so the summary reports only the rows // removed *via* the self-reference. let self_referential = rel.other_table == target_table; if self_referential { rows_changed -= rows_affected as i64; } if rows_changed > 0 { cascade.push(CascadeEffect { relationship_name: rel.name.clone(), child_table: rel.other_table.clone(), rows_changed, action: rel.on_delete, }); // The target's CSV is already queued; only add a distinct // child table (a self-ref child is the target itself). if !self_referential { rewritten_tables.push(rel.other_table.clone()); } } } let changes = Changes { schema_dirty: false, rewritten_tables, ..Changes::default() }; finalize_persistence(conn, persistence, source, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { rows_affected, cascade, data, }) } /// Execute a grammar-validated SQL `SELECT` and collect its /// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 + /// Amendment 1). /// /// Per-column playground types are recovered from the engine's /// column-origin metadata (`column_table_name` / /// `column_origin_name`, surfaced by rusqlite's /// `columns_with_metadata`). The Amendment-1 empirical probe /// confirmed the metadata follows through non-recursive CTEs, /// scalar subqueries, derived tables, set ops, and JOINs; only /// computed projections and recursive-CTE result columns return /// `None`. The renderer (ADR-0016) handles typed columns /// (bool → true/false, etc.) and falls back to neutral /// alignment for `None`. /// Execute a grammar-validated SQL DML statement carrying a /// `RETURNING` clause and collect its returned rows into a /// [`DataResult`] (ADR-0033 §5, sub-phase 3g). /// /// A `RETURNING` DML, when stepped, *both* performs the mutation /// *and* yields one result row per affected row — so `query_map` /// does the write and the read in one pass. Result-column /// playground types are recovered via the same column-origin path /// SELECT uses (`resolve_select_column_types`), so a bare-column /// `RETURNING` ref renders with its playground type; computed /// projections stay typeless. `params` carries the bound values for /// the `shortid` auto-fill rewrite (empty on the verbatim path). /// /// `table_name` labels the result for the renderer; the columns are /// the RETURNING projection, which may not be the table's columns /// (aliases, expressions), exactly as for a SELECT. fn run_returning( conn: &Connection, sql: &str, params: &[rusqlite::types::Value], table_name: &str, ) -> Result { let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; let column_names: Vec = stmt.column_names().into_iter().map(String::from).collect(); let col_count = column_names.len(); let column_types = resolve_select_column_types(conn, &stmt); let rows_iter = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { let mut cells: Vec = Vec::with_capacity(col_count); for i in 0..col_count { cells.push(row.get(i)?); } Ok(cells) }) .map_err(DbError::from_rusqlite)?; let mut rows: Vec>> = Vec::new(); for r in rows_iter { let cells = r.map_err(DbError::from_rusqlite)?; rows.push( cells .into_iter() .enumerate() .map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten())) .collect(), ); } Ok(DataResult { table_name: table_name.to_string(), columns: column_names, column_types, rows, }) } fn do_run_select(conn: &Connection, sql: &str) -> Result { debug!(sql = %sql, "run_select"); let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; let column_names: Vec = stmt .column_names() .into_iter() .map(String::from) .collect(); let col_count = column_names.len(); let column_types = resolve_select_column_types(conn, &stmt); let rows_iter = stmt .query_map([], |row| { let mut cells: Vec = Vec::with_capacity(col_count); for i in 0..col_count { cells.push(row.get(i)?); } Ok(cells) }) .map_err(DbError::from_rusqlite)?; let mut rows: Vec>> = Vec::new(); for r in rows_iter { let cells = r.map_err(DbError::from_rusqlite)?; rows.push( cells .into_iter() .enumerate() .map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten())) .collect(), ); } Ok(DataResult { table_name: String::new(), columns: column_names, column_types, rows, }) } /// Resolve playground types for each result column of a /// prepared SELECT statement (ADR-0032 §12 + Amendment 1). /// /// For each result column, query the engine's column-origin /// metadata. If both `table_name` and `origin_name` are /// populated (the result column traces back to a base-table /// column), look up the playground type in /// `__rdbms_playground_columns`. Otherwise the slot stays /// `None` — Amendment 1 documents that recursive-CTE result /// columns and computed projections are the only structural /// classes that don't follow through. fn resolve_select_column_types( conn: &Connection, stmt: &rusqlite::Statement, ) -> Vec> { let metas = stmt.columns_with_metadata(); if metas.is_empty() { return Vec::new(); } // Prepare the lookup once; reuse across columns. let mut lookup = match conn.prepare(&format!( "SELECT user_type FROM {META_TABLE} \ WHERE table_name = ?1 COLLATE NOCASE \ AND column_name = ?2 COLLATE NOCASE" )) { Ok(s) => s, Err(_) => return vec![None; metas.len()], }; metas .iter() .map(|m| { let table = m.table_name()?; let origin = m.origin_name()?; lookup .query_row(rusqlite::params![table, origin], |row| { row.get::<_, String>(0) }) .ok() .and_then(|kw| kw.parse::().ok()) }) .collect() } /// Build the parameterised `SELECT … FROM …` statement for a /// `show data` query (ADR-0026 §5–§6). Separated from /// `do_query_data` so the `explain` path runs `EXPLAIN QUERY /// PLAN` against the *exact* same statement (ADR-0028 §2). /// /// A `limit` implies a stable primary-key `ORDER BY` so /// `limit n` is "first n by primary key" rather than an /// arbitrary subset. fn build_query_data_sql( schema: &ReadSchema, table: &str, filter: Option<&Expr>, limit: Option, ) -> (String, Vec) { let cols_csv = schema .columns .iter() .map(|c| quote_ident(&c.name)) .collect::>() .join(", "); let mut params: Vec = Vec::new(); let where_sql = filter.map_or_else(String::new, |expr| { format!(" WHERE {}", compile_expr(expr, schema, &mut params)) }); let (order_sql, limit_sql) = limit.map_or_else( || (String::new(), String::new()), |n| { let order = if schema.primary_key.is_empty() { String::new() } else { let pk = schema .primary_key .iter() .map(|c| quote_ident(c)) .collect::>() .join(", "); format!(" ORDER BY {pk}") }; params.push(rusqlite::types::Value::Integer( i64::try_from(n).unwrap_or(i64::MAX), )); (order, format!(" LIMIT ?{}", params.len())) }, ); let sql = format!( "SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};", ident = quote_ident(table), ); (sql, params) } fn do_query_data( conn: &Connection, table: &str, filter: Option<&Expr>, limit: Option, ) -> Result { let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); let (sql, params) = build_query_data_sql(&schema, table, filter, limit); debug!(sql = %sql, "query_data"); let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let rows_iter = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { let mut cells: Vec = Vec::with_capacity(column_names.len()); for i in 0..column_names.len() { let v: rusqlite::types::Value = row.get(i)?; cells.push(v); } Ok(cells) }) .map_err(DbError::from_rusqlite)?; let mut rows: Vec>> = Vec::new(); for r in rows_iter { let cells = r.map_err(DbError::from_rusqlite)?; let formatted: Vec> = cells .into_iter() .zip(column_types.iter()) .map(|(v, ty)| format_cell(v, *ty)) .collect(); rows.push(formatted); } Ok(DataResult { table_name: table.to_string(), columns: column_names, column_types, rows, }) } fn format_cell(value: rusqlite::types::Value, ty: Option) -> Option { use rusqlite::types::Value as V; match value { V::Null => None, V::Integer(i) => Some(if matches!(ty, Some(Type::Bool)) { (if i == 0 { "false" } else { "true" }).to_string() } else { i.to_string() }), V::Real(r) => Some(format!("{r}")), V::Text(s) => Some(s), V::Blob(b) => Some(format!("", b.len())), } } /// Capture the query plan for an explainable command /// (ADR-0028 §2). Matches the inner command, builds the exact /// SQL it would otherwise run via the shared `build_*_sql` /// helpers, runs `EXPLAIN QUERY PLAN` against it (which never /// executes the statement), and pairs the plan rows with a /// standard-SQL display form of the statement. fn do_explain_plan(conn: &Connection, query: &Command) -> Result { let (exec_sql, params) = match query { Command::ShowData { name, filter, limit, } => { let schema = read_schema(conn, name)?; build_query_data_sql(&schema, name, filter.as_ref(), *limit) } Command::Update { table, assignments, filter, } => { let schema = read_schema(conn, table)?; build_update_sql(&schema, table, assignments, filter)? } Command::Delete { table, filter } => { let schema = read_schema(conn, table)?; build_delete_sql(&schema, table, filter) } other => { // The grammar only ever wraps the three explainable // commands; a different inner command means a // synthesised `Command::Explain` (tests, scripting). return Err(DbError::Unsupported(format!( "cannot explain `{}`", other.verb() ))); } }; let rows = run_explain_query_plan(conn, &exec_sql, ¶ms)?; let display_sql = inline_params_for_display(&exec_sql, ¶ms); debug!(sql = %display_sql, rows = rows.len(), "explain_plan"); Ok(QueryPlan { display_sql, rows }) } /// Prepare `EXPLAIN QUERY PLAN ` and read back its tree /// rows. The inner statement's parameters are bound so the /// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines /// the plan from structure, not parameter values (ADR-0028 §2). fn run_explain_query_plan( conn: &Connection, sql: &str, params: &[rusqlite::types::Value], ) -> Result, DbError> { let explain_sql = format!("EXPLAIN QUERY PLAN {sql}"); let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?; // `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)` // — the `notused` column at index 2 is dropped. let rows_iter = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { Ok(ExplainRow { id: row.get(0)?, parent: row.get(1)?, detail: row.get(3)?, }) }) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for r in rows_iter { out.push(r.map_err(DbError::from_rusqlite)?); } Ok(out) } /// Render execution SQL as human-facing display SQL by inlining /// each `?N` placeholder as a standard-SQL literal (ADR-0028 /// §3). The scan is quote-aware — a `?N`-shaped run inside a /// double-quoted identifier is left untouched — and the /// trailing `;` is dropped so the result reads as a query. fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String { let mut out = String::with_capacity(sql.len()); let mut chars = sql.chars().peekable(); let mut in_quote = false; while let Some(c) = chars.next() { match c { '"' => { in_quote = !in_quote; out.push('"'); } '?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => { let mut digits = String::new(); while let Some(d) = chars.peek() { if d.is_ascii_digit() { digits.push(*d); chars.next(); } else { break; } } match digits .parse::() .ok() .and_then(|n| n.checked_sub(1)) .and_then(|idx| params.get(idx)) { Some(value) => out.push_str(&sql_literal(value)), // Out of range — leave the placeholder verbatim. None => { out.push('?'); out.push_str(&digits); } } } other => out.push(other), } } out.trim_end().trim_end_matches(';').trim_end().to_string() } /// Render a bound parameter as a standard-SQL literal for the /// display SQL (ADR-0028 §3). Text is single-quoted with /// embedded quotes doubled; a real with no fractional part /// keeps a `.0` so it still reads as a real. fn sql_literal(value: &rusqlite::types::Value) -> String { use rusqlite::types::Value as V; match value { V::Null => "NULL".to_string(), V::Integer(i) => i.to_string(), V::Real(r) => { let s = r.to_string(); if s.contains(['.', 'e', 'E']) { s } else { format!("{s}.0") } } V::Text(s) => format!("'{}'", s.replace('\'', "''")), V::Blob(bytes) => { let mut hex = String::with_capacity(bytes.len() * 2 + 3); hex.push_str("x'"); for b in bytes { hex.push_str(&format!("{b:02x}")); } hex.push('\''); hex } } } fn read_relationships_outbound( conn: &Connection, table: &str, ) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT name, parent_table, parent_column, child_column, on_delete, on_update \ FROM {REL_TABLE} \ WHERE child_table = ?1 \ ORDER BY name;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([table], |row| { let on_delete: String = row.get(4)?; let on_update: String = row.get(5)?; Ok(RelationshipEnd { name: row.get(0)?, other_table: row.get(1)?, other_column: row.get(2)?, local_column: row.get(3)?, on_delete: on_delete .parse::() .unwrap_or(ReferentialAction::NoAction), on_update: on_update .parse::() .unwrap_or(ReferentialAction::NoAction), }) }) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } fn read_relationships_inbound( conn: &Connection, table: &str, ) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT name, child_table, child_column, parent_column, on_delete, on_update \ FROM {REL_TABLE} \ WHERE parent_table = ?1 \ ORDER BY name;" )) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([table], |row| { let on_delete: String = row.get(4)?; let on_update: String = row.get(5)?; Ok(RelationshipEnd { name: row.get(0)?, other_table: row.get(1)?, other_column: row.get(2)?, local_column: row.get(3)?, on_delete: on_delete .parse::() .unwrap_or(ReferentialAction::NoAction), on_update: on_update .parse::() .unwrap_or(ReferentialAction::NoAction), }) }) .map_err(DbError::from_rusqlite)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(DbError::from_rusqlite)?); } Ok(out) } /// Rebuild the database from `project.yaml` + `data/
.csv` /// (ADR-0015 §7). /// /// The on-disk text is the authoritative source: this function /// recreates schema, metadata, and rows so the resulting `.db` /// reflects them exactly. Persistence callbacks are NOT invoked /// for the schema/data writes; we're loading, not changing /// user-visible state. The exception is `history.log`: when /// `source` is `Some`, the rebuild was user-initiated (via the /// `rebuild` app-level command) and is appended as a successful /// command per ADR-0015 §5. /// /// Existing user tables and metadata rows are wiped at the /// start of the rebuild so this function works on both fresh /// and populated databases — the silent on-load case (empty /// db) sees a no-op wipe; the explicit `rebuild` command /// replaces whatever was there. /// /// FK enforcement is disabled for the load and re-enabled at /// the end (regardless of success). A `foreign_key_check` /// before commit verifies the loaded data is consistent — any /// violation aborts with a fatal error. fn do_rebuild_from_text( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, project_path: &Path, ) -> Result<(), DbError> { let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); let yaml_body = std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal { operation: "read", path: yaml_path.clone(), message: e.to_string(), })?; let snapshot = parse_schema(&yaml_body).map_err(|e| DbError::PersistenceFatal { operation: "parse", path: yaml_path.clone(), message: e.to_string(), })?; conn.execute_batch("PRAGMA foreign_keys = OFF;") .map_err(DbError::from_rusqlite)?; let result = (|| -> Result<(), DbError> { let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; // 0. Wipe any existing user tables + metadata so the // rebuild can start from a clean slate. This step // is a no-op on a freshly-created database (the // silent rebuild on missing-`.db`); on the explicit // `rebuild` command it replaces the live state with // the text-source state per ADR-0015 §7. let existing_tables = do_list_tables(&tx)?; for name in &existing_tables { tx.execute_batch(&format!( "DROP TABLE {ident};", ident = quote_ident(name) )) .map_err(DbError::from_rusqlite)?; } tx.execute_batch(&format!( "DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};" )) .map_err(DbError::from_rusqlite)?; // 0b. The table-level CHECK metadata is the source of truth that // the engine cannot report (ADR-0035 §4a.3), so — like // META/REL — it is wiped and repopulated from the YAML // snapshot here (step 3b). Without this a fresh rebuild // (missing `.db`) would enforce the CHECK via the recreated // DDL but leave `CHECK_TABLE` empty, so `describe` / `DROP // CONSTRAINT` / a later save would lose it. The rebuild also // **migrates** a pre-§4g table that predates the `name` // column (the rebuild-only migration, ADR-0035 §4g): add it // if absent before repopulating with names. if !check_table_has_name_column(&tx)? { tx.execute_batch(&format!("ALTER TABLE {CHECK_TABLE} ADD COLUMN name TEXT;")) .map_err(DbError::from_rusqlite)?; } tx.execute_batch(&format!("DELETE FROM {CHECK_TABLE};")) .map_err(DbError::from_rusqlite)?; // 1. Recreate user tables with FK constraints inline. for table in &snapshot.tables { let read_schema = build_read_schema(table, &snapshot.relationships); let ddl = schema_to_ddl(&table.name, &read_schema); tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; } // 2. Column-type metadata. { let mut stmt = tx .prepare(&format!( "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ VALUES (?1, ?2, ?3);" )) .map_err(DbError::from_rusqlite)?; for table in &snapshot.tables { for col in &table.columns { stmt.execute([ table.name.as_str(), col.name.as_str(), col.user_type.keyword(), ]) .map_err(DbError::from_rusqlite)?; } } } // 3. Relationship metadata. { let mut stmt = tx .prepare(&format!( "INSERT INTO {REL_TABLE} \ (name, parent_table, parent_column, child_table, child_column, \ on_delete, on_update) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);" )) .map_err(DbError::from_rusqlite)?; for rel in &snapshot.relationships { stmt.execute([ rel.name.as_str(), rel.parent_table.as_str(), rel.parent_column.as_str(), rel.child_table.as_str(), rel.child_column.as_str(), rel.on_delete.keyword(), rel.on_update.keyword(), ]) .map_err(DbError::from_rusqlite)?; } } // 3b. Table-level CHECK metadata (ADR-0035 §4a.3 / §4g) — in // declaration order (`seq`), carrying the optional name so a // named CHECK round-trips through the rebuild. { let mut stmt = tx .prepare(&format!( "INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \ VALUES (?1, ?2, ?3, ?4);" )) .map_err(DbError::from_rusqlite)?; for table in &snapshot.tables { for (seq, check) in table.check_constraints.iter().enumerate() { stmt.execute(rusqlite::params![ table.name, seq as i64, check.expr, check.name, ]) .map_err(DbError::from_rusqlite)?; } } } // 4. Project metadata: overwrite the configure-time // `created_at` with the YAML's authoritative value. tx.execute( &format!( "INSERT INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1) \ ON CONFLICT(key) DO UPDATE SET value = excluded.value;" ), [snapshot.created_at.as_str()], ) .map_err(DbError::from_rusqlite)?; // 5. Load each table's rows (if a CSV is present). for table in &snapshot.tables { let csv_path = data_dir.join(format!("{}.csv", table.name)); if !csv_path.exists() { continue; } load_table_csv(&tx, table, &csv_path)?; } // 5b. Recreate indexes (ADR-0025). Done after the data // load — the result is identical either way, and // this keeps the structural steps (tables, FKs, // data) ahead of the derived index objects. for index in &snapshot.indexes { let cols = index .columns .iter() .map(|c| quote_ident(c)) .collect::>() .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 {unique_kw}INDEX {idx} ON {tbl} ({cols});", idx = quote_ident(&index.name), tbl = quote_ident(&index.table), )) .map_err(DbError::from_rusqlite)?; } // 6. Verify FK consistency before committing. { let mut check = tx .prepare("PRAGMA foreign_key_check;") .map_err(DbError::from_rusqlite)?; let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; if rows.next().map_err(DbError::from_rusqlite)?.is_some() { return Err(DbError::PersistenceFatal { operation: "rebuild", path: yaml_path.clone(), message: "rebuilt data violates foreign-key constraints".to_string(), }); } } // 7. Append `history.log` if this rebuild was // user-initiated (the silent on-load case has // `source = None`). if let (Some(p), Some(text)) = (persistence, source) { p.append_history(text) .map_err(DbError::from_persistence)?; } tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) })(); let pragma_result = conn .execute_batch("PRAGMA foreign_keys = ON;") .map_err(DbError::from_rusqlite); result.and(pragma_result) } /// Build a `ReadSchema` for `table` that includes any /// relationships from the snapshot in which `table` is the /// child. The output drives `schema_to_ddl` so the resulting /// CREATE TABLE has the FKs inline. fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) -> ReadSchema { let columns: Vec = table .columns .iter() .map(|c| ReadColumn { name: c.name.clone(), sqlite_type: c.user_type.sqlite_strict_type().to_string(), notnull: c.not_null, primary_key: table.primary_key.contains(&c.name), unique: c.unique, default_sql: c.default.clone(), check: c.check.clone(), user_type: Some(c.user_type), }) .collect(); let foreign_keys: Vec = relationships .iter() .filter(|r| r.child_table == table.name) .map(|r| ReadForeignKey { parent_table: r.parent_table.clone(), parent_column: r.parent_column.clone(), child_column: r.child_column.clone(), on_delete: r.on_delete, on_update: r.on_update, }) .collect(); ReadSchema { columns, primary_key: table.primary_key.clone(), foreign_keys, unique_constraints: table.unique_constraints.clone(), check_constraints: table.check_constraints.clone(), } } /// Read `csv_path` and INSERT each row into `table.name`. /// Failures are wrapped in `DbError::RebuildRowFailed` with /// row number and table name per ADR-0015 §7. fn load_table_csv( tx: &rusqlite::Transaction<'_>, table: &TableSchema, csv_path: &Path, ) -> Result<(), DbError> { let body = std::fs::read_to_string(csv_path).map_err(|e| DbError::PersistenceFatal { operation: "read", path: csv_path.to_path_buf(), message: e.to_string(), })?; let parsed = parse_csv(&body).map_err(|e| DbError::PersistenceFatal { operation: "parse", path: csv_path.to_path_buf(), message: e.to_string(), })?; if parsed.rows.is_empty() { return Ok(()); } // Header sanity check: column names must match the YAML // schema's column order. A mismatch is a hand-edit hazard; // surfacing it as a fatal error is better than silently // mis-aligning columns. let expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect(); let header_strs: Vec<&str> = parsed.header.iter().map(String::as_str).collect(); if header_strs != expected { return Err(DbError::PersistenceFatal { operation: "validate", path: csv_path.to_path_buf(), message: format!( "CSV header {:?} does not match table columns {:?}", parsed.header, expected, ), }); } let cols_csv = table .columns .iter() .map(|c| quote_ident(&c.name)) .collect::>() .join(", "); let placeholders = (1..=table.columns.len()) .map(|i| format!("?{i}")) .collect::>() .join(", "); let sql = format!( "INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});", ident = quote_ident(&table.name), ); let mut stmt = tx.prepare(&sql).map_err(DbError::from_rusqlite)?; for (idx, raw_row) in parsed.rows.iter().enumerate() { // Row number reported as a 1-based file line: header // is line 1, so the first data row is line 2. let row_number = idx + 2; if raw_row.len() != table.columns.len() { return Err(DbError::RebuildRowFailed { table: table.name.clone(), csv_path: csv_path.to_path_buf(), row_number, detail: format!( "row has {} field(s) but table has {} column(s)", raw_row.len(), table.columns.len(), ), }); } let mut params: Vec = Vec::with_capacity(raw_row.len()); for (col, raw_cell) in table.columns.iter().zip(raw_row.iter()) { let cell = decode_cell(col.user_type, raw_cell).map_err(|detail| { DbError::RebuildRowFailed { table: table.name.clone(), csv_path: csv_path.to_path_buf(), row_number, detail: format!("column `{}`: {detail}", col.name), } })?; params.push(cell_value_to_sqlite(&cell)); } stmt.execute(rusqlite::params_from_iter(params.iter())) .map_err(|e| DbError::RebuildRowFailed { table: table.name.clone(), csv_path: csv_path.to_path_buf(), row_number, detail: e.to_string(), })?; } Ok(()) } fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value { use rusqlite::types::Value; match cell { CellValue::Null => Value::Null, CellValue::Integer(n) => Value::Integer(*n), CellValue::Real(f) => Value::Real(*f), CellValue::Text(s) => Value::Text(s.clone()), CellValue::Blob(b) => Value::Blob(b.clone()), } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn db() -> Database { Database::open(":memory:").expect("open in-memory") } fn col(name: &str, ty: Type) -> ColumnSpec { ColumnSpec::new(name, ty) } /// Convenience: a `serial`-PK table with a single `id` column. async fn make_id_table(db: &Database, name: &str) -> TableDescription { db.create_table( name.to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .expect("create table") } #[tokio::test] async fn open_in_memory_succeeds() { let _ = db(); } #[tokio::test] async fn create_table_with_serial_pk_appears_in_list() { let db = db(); make_id_table(&db, "Customers").await; let tables = db.list_tables().await.unwrap(); assert_eq!(tables, vec!["Customers".to_string()]); } #[tokio::test] async fn create_table_with_serial_pk_describes_correctly() { let db = db(); let desc = make_id_table(&db, "Customers").await; assert_eq!(desc.name, "Customers"); assert_eq!(desc.columns.len(), 1); let id = &desc.columns[0]; assert_eq!(id.name, "id"); assert!(id.primary_key); assert_eq!(id.user_type, Some(Type::Serial)); assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER"); } #[tokio::test] async fn create_table_with_text_pk_works() { let db = db(); let desc = db .create_table( "Customers".to_string(), vec![col("email", Type::Text)], vec!["email".to_string()], None) .await .unwrap(); assert_eq!(desc.columns.len(), 1); assert_eq!(desc.columns[0].name, "email"); assert_eq!(desc.columns[0].user_type, Some(Type::Text)); assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT"); assert!(desc.columns[0].primary_key); } #[tokio::test] async fn create_table_with_compound_pk_works() { let db = db(); let desc = db .create_table( "OrderLines".to_string(), vec![col("order_id", Type::Int), col("product_id", Type::Int)], vec!["order_id".to_string(), "product_id".to_string()], None) .await .unwrap(); assert_eq!(desc.columns.len(), 2); assert!(desc.columns.iter().all(|c| c.primary_key)); } #[tokio::test] async fn create_table_with_pedagogically_unusual_pk_type_still_works() { // The grammar lets users try anything; the DB layer just // does what they ask. let db = db(); let desc = db .create_table( "T".to_string(), vec![col("flag", Type::Bool)], vec!["flag".to_string()], None) .await .unwrap(); assert!(desc.columns[0].primary_key); } #[tokio::test] async fn create_table_rejects_zero_columns() { let db = db(); let err = db .create_table("T".to_string(), Vec::new(), Vec::new(), None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn drop_table_removes_it_from_list() { let db = db(); make_id_table(&db, "T").await; db.drop_table("T".to_string(), None).await.unwrap(); let tables = db.list_tables().await.unwrap(); assert!(tables.is_empty()); } #[tokio::test] async fn add_column_appends_to_existing_table() { let db = db(); make_id_table(&db, "Customers").await; let result = db .add_column("Customers".to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) .await .unwrap(); let desc = &result.description; let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["id", "Name"]); let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap(); assert_eq!(name_col.user_type, Some(Type::Text)); assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT"); assert!(result.client_side_notes.is_empty(), "no auto-fill for plain types"); } #[tokio::test] async fn user_facing_types_round_trip_through_metadata() { let db = db(); // Create with a serial PK and add columns of every type // that would otherwise be erased by SQLite (date, // datetime, decimal — all backed by TEXT). make_id_table(&db, "T").await; for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { db.add_column("T".to_string(), ColumnSpec::new(format!("c_{ty}"), ty), None) .await .unwrap(); } let desc = db.describe_table("T".to_string(), None).await.unwrap(); let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap(); assert_eq!(id_col.user_type, Some(Type::Serial)); for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { let col_name = format!("c_{ty}"); let c = desc.columns.iter().find(|c| c.name == col_name).unwrap(); assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}"); } } #[tokio::test] async fn list_tables_excludes_internal_metadata_table() { let db = db(); make_id_table(&db, "Visible").await; let tables = db.list_tables().await.unwrap(); assert_eq!(tables, vec!["Visible".to_string()]); // Metadata table is present in the underlying schema but // hidden from list_tables. } #[tokio::test] async fn drop_table_clears_metadata_so_recreate_starts_fresh() { let db = db(); // Create with a date column. db.create_table( "T".to_string(), vec![col("when", Type::Date)], vec!["when".to_string()], None) .await .unwrap(); let before = db.describe_table("T".to_string(), None).await.unwrap(); assert_eq!(before.columns[0].user_type, Some(Type::Date)); // Drop it. db.drop_table("T".to_string(), None).await.unwrap(); // Recreate with a different type for the same-named column; // the metadata for the new table must reflect the new type // (i.e. metadata from the previous incarnation must not // bleed through). db.create_table( "T".to_string(), vec![col("when", Type::DateTime)], vec!["when".to_string()], None) .await .unwrap(); let after = db.describe_table("T".to_string(), None).await.unwrap(); assert_eq!(after.columns[0].user_type, Some(Type::DateTime)); } #[tokio::test] async fn add_column_for_each_value_type() { let db = db(); make_id_table(&db, "T").await; for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] { let col_name = format!("c_{ty}"); db.add_column("T".to_string(), ColumnSpec::new(col_name.clone(), ty), None) .await .unwrap_or_else(|e| panic!("type {ty} failed: {e}")); } let desc = db.describe_table("T".to_string(), None).await.unwrap(); // 5 user columns + the id PK column. assert_eq!(desc.columns.len(), 6); } #[tokio::test] async fn add_column_serial_to_empty_table_succeeds() { // ADR-0018 §6: serial / shortid via add_column are now // allowed. Empty-table case: column added, no auto-fill // [client-side] note (nothing to populate). let db = db(); make_id_table(&db, "T").await; let result = db .add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Serial), None) .await .unwrap(); let code = result .description .columns .iter() .find(|c| c.name == "code") .expect("code column added"); assert_eq!(code.user_type, Some(Type::Serial)); assert!( !code.primary_key, "non-PK serial: column is not the PK (id remains the PK)" ); assert!( result.client_side_notes.is_empty(), "empty table: no auto-fill note" ); } /// Helper: build a table with `id` (serial PK) + `Name` /// (text) and insert N rows, populating just `Name`. async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) { make_id_table(db, table).await; db.add_column(table.to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) .await .unwrap(); for i in 0..count { db.insert( table.to_string(), Some(vec!["Name".to_string()]), vec![Value::Text(format!("row{i}"))], None, ) .await .unwrap(); } } #[tokio::test] async fn add_column_serial_to_non_empty_table_auto_fills() { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db .add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); let seq = result .description .columns .iter() .find(|c| c.name == "seq") .unwrap(); assert_eq!(seq.user_type, Some(Type::Serial)); assert!(!result.client_side_notes.is_empty(), "auto-fill note expected"); assert!( result.client_side_notes[0].contains("3 row(s) given auto-generated serial"), "unexpected note: {:?}", result.client_side_notes ); // Verify the column is populated 1..3. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut filled: Vec = data .rows .iter() .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::().ok())) .collect(); filled.sort(); assert_eq!(filled, vec![1, 2, 3]); } #[tokio::test] async fn add_column_shortid_to_non_empty_table_auto_fills() { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db .add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::ShortId), None) .await .unwrap(); let tag = result .description .columns .iter() .find(|c| c.name == "tag") .unwrap(); assert_eq!(tag.user_type, Some(Type::ShortId)); assert!(!result.client_side_notes.is_empty(), "auto-fill note expected"); assert!( result.client_side_notes[0].contains("3 row(s) given auto-generated shortid"), "unexpected note: {:?}", result.client_side_notes ); // Verify each row has a non-null shortid value. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled"); assert!( v.len() >= 10 && v.len() <= 12, "shortid length out of range: {v}" ); } } #[tokio::test] async fn insert_auto_fills_non_pk_serial_column_sequentially() { // ADR-0018 §5: non-PK serial columns get MAX(col)+1 // automatically when the user omits them. PK serial // columns continue to use SQLite's rowid alias. let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Insert three rows providing only `Name`. The seq // column should auto-fill 1, 2, 3. for n in ["a", "b", "c"] { db.insert( "T".to_string(), Some(vec!["Name".to_string()]), vec![Value::Text(n.to_string())], None, ) .await .unwrap(); } let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows .iter() .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::().ok())) .collect(); values.sort(); assert_eq!(values, vec![1, 2, 3]); } #[tokio::test] async fn insert_non_pk_serial_continues_past_explicit_value() { // If the user explicitly inserts a high value, MAX+1 // sequencing jumps past it. Gappy sequences are // accepted (ADR-0018 Resolution 2). let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Insert with explicit seq=100. db.insert( "T".to_string(), Some(vec!["Name".to_string(), "seq".to_string()]), vec![Value::Text("a".to_string()), Value::Number("100".to_string())], None, ) .await .unwrap(); // Next omitted-seq insert should auto-fill 101. db.insert( "T".to_string(), Some(vec!["Name".to_string()]), vec![Value::Text("b".to_string())], None, ) .await .unwrap(); let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows .iter() .filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::().ok())) .collect(); values.sort(); assert_eq!(values, vec![100, 101]); } #[tokio::test] async fn add_column_serial_emits_unique_constraint() { // Non-PK serial gains UNIQUE per ADR-0018 §4. Verify by // attempting to UPDATE all rows to the same value and // confirming the engine refuses. let db = db(); make_table_with_n_rows(&db, "T", 2).await; db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Attempt to UPDATE one row to have the same `seq` value // as the other — should violate UNIQUE. let err = db .update( "T".to_string(), vec![("seq".to_string(), Value::Number("1".to_string()))], RowFilter::AllRows, None, ) .await .unwrap_err(); // SQLite reports as a constraint violation; classified // as UniqueViolation. match err { DbError::Sqlite { kind, .. } => { assert_eq!(kind, SqliteErrorKind::UniqueViolation); } other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn create_table_duplicate_returns_already_exists() { let db = db(); make_id_table(&db, "T").await; let err = db .create_table( "T".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists), other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn drop_nonexistent_table_returns_no_such_table() { let db = db(); let err = db.drop_table("Ghost".to_string(), None).await.unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable), other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn add_column_to_missing_table_returns_no_such_table() { let db = db(); let err = db .add_column("Ghost".to_string(), ColumnSpec::new("x".to_string(), Type::Text), None) .await .unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable), other => panic!("unexpected error: {other:?}"), } } // --- drop_column / rename_column / change_column_type --- #[tokio::test] async fn drop_column_removes_column_and_data() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) .await .unwrap(); db.insert( "T".to_string(), None, vec![Value::Number("42".to_string())], None, ) .await .unwrap(); let result = db .drop_column("T".to_string(), "Score".to_string(), false, None) .await .unwrap(); let names: Vec<_> = result .description .columns .iter() .map(|c| c.name.as_str()) .collect(); assert_eq!(names, vec!["id"]); // Row data still accessible (id was preserved); the // dropped column is gone from the projection. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string()]); assert_eq!(data.rows.len(), 1); } #[tokio::test] async fn drop_column_refuses_primary_key() { let db = db(); make_id_table(&db, "T").await; let err = db .drop_column("T".to_string(), "id".to_string(), false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); let msg = format!("{err}"); assert!(msg.to_lowercase().contains("primary"), "{msg}"); } #[tokio::test] async fn drop_column_refuses_column_in_a_relationship() { let db = db(); // Customers(id PK) ← Orders(cust_id FK) make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); // Try to drop the FK column on the child side. let err = db .drop_column("Orders".to_string(), "cust_id".to_string(), false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); assert!(format!("{err}").contains("relationship")); } #[tokio::test] async fn drop_column_for_missing_column_errors() { let db = db(); make_id_table(&db, "T").await; let err = db .drop_column("T".to_string(), "Ghost".to_string(), false, None) .await .unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchColumn), other => panic!("unexpected error: {other:?}"), } } // --- indexes (ADR-0025) ----------------------------------- /// A `serial`-PK table with one extra text `Email` column — /// something indexable. async fn make_indexable_table(db: &Database, name: &str) { make_id_table(db, name).await; db.add_column(name.to_string(), ColumnSpec::new("Email".to_string(), Type::Text), None) .await .expect("add Email column"); } #[tokio::test] async fn add_index_appears_in_description() { let db = db(); make_indexable_table(&db, "Customers").await; let desc = db .add_index( Some("idx_email".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .expect("add index"); assert_eq!(desc.indexes.len(), 1); assert_eq!(desc.indexes[0].name, "idx_email"); assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]); } #[tokio::test] async fn add_index_auto_generates_name() { let db = db(); make_indexable_table(&db, "Customers").await; let desc = db .add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) .await .expect("add index"); assert_eq!(desc.indexes[0].name, "Customers_Email_idx"); } #[tokio::test] async fn add_index_composite_auto_name_joins_columns() { let db = db(); make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) .await .unwrap(); db.add_column("Orders".to_string(), ColumnSpec::new("Day".to_string(), Type::Date), None) .await .unwrap(); let desc = db .add_index( None, "Orders".to_string(), vec!["CustId".to_string(), "Day".to_string()], None, ) .await .expect("add index"); assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx"); assert_eq!( desc.indexes[0].columns, vec!["CustId".to_string(), "Day".to_string()] ); } #[tokio::test] async fn add_index_rejects_duplicate_name() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_column("Customers".to_string(), ColumnSpec::new("Nick".to_string(), Type::Text), None) .await .unwrap(); db.add_index( Some("idx".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let err = db .add_index( Some("idx".to_string()), "Customers".to_string(), vec!["Nick".to_string()], None, ) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn add_index_rejects_redundant_column_set() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_index( Some("a".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let err = db .add_index( Some("b".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn add_index_rejects_missing_column() { let db = db(); make_indexable_table(&db, "Customers").await; let err = db .add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None) .await .unwrap_err(); assert!( matches!( err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchColumn, .. } ), "got {err:?}" ); } #[tokio::test] async fn add_index_rejects_missing_table() { let db = db(); let err = db .add_index(None, "Ghost".to_string(), vec!["x".to_string()], None) .await .unwrap_err(); assert!( matches!( err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. } ), "got {err:?}" ); } #[tokio::test] async fn drop_index_by_name_removes_it() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_index( Some("idx_email".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let desc = db .drop_index( IndexSelector::Named { name: "idx_email".to_string(), }, None, ) .await .expect("drop index"); assert!(desc.indexes.is_empty()); } #[tokio::test] async fn drop_index_by_columns_removes_it() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) .await .unwrap(); let desc = db .drop_index( IndexSelector::Columns { table: "Customers".to_string(), columns: vec!["Email".to_string()], }, None, ) .await .expect("drop index"); assert!(desc.indexes.is_empty()); } #[tokio::test] async fn drop_index_unknown_name_errors() { let db = db(); make_indexable_table(&db, "Customers").await; let err = db .drop_index( IndexSelector::Named { name: "nope".to_string(), }, None, ) .await .unwrap_err(); assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}"); } #[tokio::test] async fn drop_column_refuses_indexed_column_without_cascade() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_index( Some("idx_email".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let err = db .drop_column("Customers".to_string(), "Email".to_string(), false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); assert!(format!("{err}").contains("idx_email"), "got {err}"); } #[tokio::test] async fn drop_column_cascade_drops_covering_index() { let db = db(); make_indexable_table(&db, "Customers").await; db.add_index( Some("idx_email".to_string()), "Customers".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let result = db .drop_column("Customers".to_string(), "Email".to_string(), true, None) .await .expect("drop column --cascade"); assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]); assert!(result.description.indexes.is_empty()); assert!( result .description .columns .iter() .all(|c| c.name != "Email"), ); } #[tokio::test] async fn rebuild_table_preserves_indexes() { // `change column` rebuilds the table; an index on an // unrelated column must survive the rebuild (ADR-0025). let db = db(); make_indexable_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) .await .unwrap(); db.add_index( Some("idx_email".to_string()), "T".to_string(), vec!["Email".to_string()], None, ) .await .unwrap(); let result = db .change_column_type( "T".to_string(), "Score".to_string(), Type::Real, ChangeColumnMode::Default, None, ) .await .expect("change column type"); assert_eq!(result.description.indexes.len(), 1); assert_eq!(result.description.indexes[0].name, "idx_email"); assert_eq!( result.description.indexes[0].columns, vec!["Email".to_string()] ); } #[tokio::test] async fn rename_column_updates_schema_and_metadata() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Old".to_string(), Type::Text), None) .await .unwrap(); let desc = db .rename_column("T".to_string(), "Old".to_string(), "New".to_string(), None) .await .unwrap(); let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["id", "New"]); // user_type metadata was preserved through the rename. let new_col = desc.columns.iter().find(|c| c.name == "New").unwrap(); assert_eq!(new_col.user_type, Some(Type::Text)); } #[tokio::test] async fn rename_column_propagates_to_relationship_metadata() { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); // Rename on the child side; SQLite cascades the FK // declaration, and we mirror that into our metadata. db.rename_column( "Orders".to_string(), "cust_id".to_string(), "buyer_id".to_string(), None, ) .await .unwrap(); let orders = db .describe_table("Orders".to_string(), None) .await .unwrap(); let outbound = orders .outbound_relationships .iter() .find(|r| r.local_column == "buyer_id"); assert!( outbound.is_some(), "expected outbound rel on `buyer_id`, got {:?}", orders.outbound_relationships, ); // Same from the parent perspective via inbound. let customers = db .describe_table("Customers".to_string(), None) .await .unwrap(); let inbound = customers .inbound_relationships .iter() .find(|r| r.other_column == "buyer_id"); assert!( inbound.is_some(), "expected inbound rel referencing `buyer_id`, got {:?}", customers.inbound_relationships, ); } #[tokio::test] async fn rename_column_refuses_collision() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); db.add_column("T".to_string(), ColumnSpec::new("B".to_string(), Type::Text), None) .await .unwrap(); let err = db .rename_column("T".to_string(), "A".to_string(), "B".to_string(), None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn rename_column_refuses_identity_rename() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); let err = db .rename_column("T".to_string(), "A".to_string(), "A".to_string(), None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn change_column_type_works_for_compatible_data() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) .await .unwrap(); // Insert numeric-looking strings. for v in ["1", "2", "3"] { db.insert( "T".to_string(), Some(vec!["Score".to_string()]), vec![Value::Text(v.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "Score".to_string(), Type::Int, ChangeColumnMode::Default, None, ) .await .unwrap(); let score = result .description .columns .iter() .find(|c| c.name == "Score") .unwrap(); assert_eq!(score.user_type, Some(Type::Int)); // Per ADR-0017 §6, the [client-side] note fires when // any cell was rewritten (storage class change counts). let note = result .client_side .expect("text -> int rewrites every cell; expected client-side note"); assert_eq!(note.transformed, 3); assert_eq!(note.lossy, 0); // Data preserved via the per-cell transformer. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } #[tokio::test] async fn change_column_type_pk_without_inbound_fk_allowed() { // ADR-0017 §4.1: a PK without inbound FKs may have its // type changed freely (no cascade to worry about). let db = db(); make_id_table(&db, "T").await; // serial -> int (canonical "drop auto-increment" case). db.change_column_type( "T".to_string(), "id".to_string(), Type::Int, ChangeColumnMode::Default, None, ) .await .expect("serial -> int on PK without inbound FK should succeed"); } #[tokio::test] async fn change_column_type_pk_with_inbound_fk_refused_when_target_type_changes() { // PK referenced by another table's FK; new type would // change `fk_target_type` (serial -> text means // fk_target_type goes Int -> Text). Refused. let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); let err = db .change_column_type( "Customers".to_string(), "id".to_string(), Type::Text, ChangeColumnMode::Default, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("referenced") || message.contains("relationship"), "expected FK-cascade language: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_pk_with_inbound_fk_allowed_when_target_type_preserved() { // PK referenced by another table's FK; serial -> int // preserves `fk_target_type` (both Int). Allowed. let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); db.change_column_type( "Customers".to_string(), "id".to_string(), Type::Int, ChangeColumnMode::Default, None, ) .await .expect("serial -> int on FK-referenced PK should succeed"); } // ---- ADR-0017 dry-run / mode / uniqueness coverage ---- #[tokio::test] async fn change_column_type_refuses_lossy_by_default() { // real -> int with fractional values: every cell is // Lossy. Default mode refuses with a friendly diagnostic // table. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) .await .unwrap(); for v in ["3.14", "2.71"] { db.insert( "T".to_string(), Some(vec!["Score".to_string()]), vec![Value::Number(v.to_string())], None, ) .await .unwrap(); } let err = db .change_column_type( "T".to_string(), "Score".to_string(), Type::Int, ChangeColumnMode::Default, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("discard information"), "expected lossy header: {message}" ); assert!( message.contains("--force-conversion"), "expected hint about --force-conversion: {message}" ); // Bordered diagnostic table is present. assert!(message.contains('┌'), "expected bordered table: {message}"); assert!( message.contains("(PK)"), "expected PK header marker: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_force_conversion_accepts_lossy() { // Same setup as above, but with --force-conversion the // change succeeds and the [client-side] note carries // the lossy count. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) .await .unwrap(); for v in ["3.14", "2.71", "5.0"] { db.insert( "T".to_string(), Some(vec!["Score".to_string()]), vec![Value::Number(v.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "Score".to_string(), Type::Int, ChangeColumnMode::ForceConversion, None, ) .await .expect("--force-conversion should accept lossy rows"); let note = result.client_side.expect("transformations happened"); assert_eq!(note.transformed, 3); // Two of three rows are fractional (lossy); 5.0 is clean. assert_eq!(note.lossy, 2); } #[tokio::test] async fn change_column_type_refuses_incompatible_even_with_force() { // text "abc" -> int: incompatible. --force-conversion // does NOT help (per ADR-0017 §5 / §2 step 3). let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); for v in ["abc", "123", "xyz"] { db.insert( "T".to_string(), Some(vec!["Note".to_string()]), vec![Value::Text(v.to_string())], None, ) .await .unwrap(); } for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] { let err = db .change_column_type( "T".to_string(), "Note".to_string(), Type::Int, mode, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("cannot be converted"), "expected incompatible header for {mode:?}: {message}" ); assert!( !message.contains("--force-conversion"), "incompatible refusal must NOT mention --force-conversion: {message}" ); } other => panic!("unexpected ({mode:?}): {other:?}"), } } } #[tokio::test] async fn change_column_type_int_to_bool_with_zero_one_succeeds() { // ADR-0017 §3 matrix: (Int, Bool) is per-cell-classified. // Values 0/1 are Clean (storage class doesn't change); the // transformer returns Value::Integer(0)/(1) unchanged, so // is_non_identity is false for every cell. No // [client-side] note is expected. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) .await .unwrap(); for v in ["0", "1", "0"] { db.insert( "T".to_string(), Some(vec!["Flag".to_string()]), vec![Value::Number(v.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "Flag".to_string(), Type::Bool, ChangeColumnMode::Default, None, ) .await .expect("int -> bool with all 0/1 values should succeed"); let flag = result .description .columns .iter() .find(|c| c.name == "Flag") .unwrap(); assert_eq!(flag.user_type, Some(Type::Bool)); assert!( result.client_side.is_none(), "int -> bool with values that map identity should not fire a client-side note: {:?}", result.client_side ); // Data preserved. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } #[tokio::test] async fn change_column_type_int_to_bool_refuses_other_values() { // ADR-0017 §3 matrix: (Int, Bool) classifies any value // other than 0/1 as Incompatible. A single offending row // triggers a refusal, and --force-conversion does not // help (incompatible is not lossy). let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) .await .unwrap(); for v in ["0", "1", "2"] { db.insert( "T".to_string(), Some(vec!["Flag".to_string()]), vec![Value::Number(v.to_string())], None, ) .await .unwrap(); } for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] { let err = db .change_column_type( "T".to_string(), "Flag".to_string(), Type::Bool, mode, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("cannot be converted"), "expected incompatible header for {mode:?}: {message}" ); assert!( message.contains("not 0 or 1"), "expected per-cell reason naming the offending value for {mode:?}: {message}" ); assert!( !message.contains("--force-conversion"), "incompatible refusal must NOT advertise --force-conversion for {mode:?}: {message}" ); } other => panic!("unexpected ({mode:?}): {other:?}"), } } } #[tokio::test] async fn change_column_type_dont_convert_skips_client_side() { // text -> int: under the per-cell matrix, "1"/"2"/"3" // would all classify Clean and fire the [client-side] // note. With --dont-convert we bypass that and rely on // engine coercion; the note is suppressed. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) .await .unwrap(); for v in ["1", "2", "3"] { db.insert( "T".to_string(), Some(vec!["Score".to_string()]), vec![Value::Text(v.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "Score".to_string(), Type::Int, ChangeColumnMode::DontConvert, None, ) .await .expect("text->int with all-clean cells should succeed under --dont-convert"); assert!( result.client_side.is_none(), "--dont-convert must NOT emit a client-side note" ); } #[tokio::test] async fn change_column_type_uniqueness_collision_on_pk_refused() { // PK column with values 3.14 and 3.7: real -> int with // --force-conversion would collapse both to 3, violating // PK uniqueness. ADR-0017 §4.3: this is incompatible // even under --force-conversion. let db = db(); db.create_table( "T".to_string(), vec![col("id", Type::Real)], vec!["id".to_string()], None, ) .await .unwrap(); for v in ["3.14", "3.7"] { db.insert( "T".to_string(), Some(vec!["id".to_string()]), vec![Value::Number(v.to_string())], None, ) .await .unwrap(); } let err = db .change_column_type( "T".to_string(), "id".to_string(), Type::Int, ChangeColumnMode::ForceConversion, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("collision") && message.contains("uniqueness"), "expected collision diagnostic: {message}" ); assert!(message.contains('┌'), "expected bordered table: {message}"); // Source rows column with PK column name in the header. assert!( message.contains("Source rows (id)"), "expected PK-aware Source rows header: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_blob_target_refused_statically() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); let err = db .change_column_type( "T".to_string(), "Note".to_string(), Type::Blob, ChangeColumnMode::Default, None, ) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn change_column_type_outbound_fk_refused() { // Child-side FK column: §4.2 says always refuse, // regardless of target type. let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); let err = db .change_column_type( "Orders".to_string(), "cust_id".to_string(), Type::Real, ChangeColumnMode::Default, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("foreign key") || message.contains("relationship"), "expected outbound-FK refusal: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_no_op_when_no_rows() { // Empty table: no cells to transform, no [client-side] // note, and the structural change goes through. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) .await .unwrap(); let result = db .change_column_type( "T".to_string(), "Note".to_string(), Type::Int, ChangeColumnMode::Default, None, ) .await .expect("empty table should still allow the type change"); assert!( result.client_side.is_none(), "no rows means no client-side note" ); let note_col = result .description .columns .iter() .find(|c| c.name == "Note") .unwrap(); assert_eq!(note_col.user_type, Some(Type::Int)); } #[tokio::test] async fn change_column_type_int_to_serial_succeeds() { // ADR-0018 §8: int → serial joins the matrix. The // column gains UNIQUE (since it's non-PK) and the // existing values are preserved. let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Insert a few rows with explicit code values. for (i, code) in [(1, 10), (2, 20), (3, 30)] { db.insert( "T".to_string(), Some(vec!["Name".to_string(), "code".to_string()]), vec![ Value::Text(format!("row{i}")), Value::Number(code.to_string()), ], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "code".to_string(), Type::Serial, ChangeColumnMode::Default, None, ) .await .expect("int -> serial should succeed"); let code = result .description .columns .iter() .find(|c| c.name == "code") .unwrap(); assert_eq!(code.user_type, Some(Type::Serial)); // No null cells, no transformation: no [client-side] // note (storage-class change for int -> serial is // identity at the value level). assert!( result.client_side.is_none(), "int -> serial with all non-null values is a metadata-only change" ); } #[tokio::test] async fn change_column_type_int_to_serial_refuses_duplicate_values() { // Duplicate non-null values would violate the UNIQUE // contract serial gains. Refused via the existing // uniqueness-collision diagnostic. let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Two rows with the same code. for i in 0..2 { db.insert( "T".to_string(), Some(vec!["Name".to_string(), "code".to_string()]), vec![Value::Text(format!("row{i}")), Value::Number("7".to_string())], None, ) .await .unwrap(); } let err = db .change_column_type( "T".to_string(), "code".to_string(), Type::Serial, ChangeColumnMode::Default, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("collision") || message.contains("uniqueness"), "expected uniqueness diagnostic: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_text_to_serial_routes_via_int_hint() { // Per ADR-0018 §8, only int → serial is supported // directly. Other source types are refused with a hint // to route through int. let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) .await .unwrap(); let err = db .change_column_type( "T".to_string(), "A".to_string(), Type::Serial, ChangeColumnMode::Default, None, ) .await .unwrap_err(); match err { DbError::Unsupported(message) => { assert!( message.contains("int"), "expected hint about routing via int: {message}" ); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn change_column_type_int_to_serial_with_nulls_auto_fills() { // Per ADR-0018 §3 / §7, null cells get sequence values // continuing from MAX of non-null values. The // [client-side] note reports the auto-fill count. let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) .await .unwrap(); // Three rows: one with code=5, two with NULL. db.insert( "T".to_string(), Some(vec!["Name".to_string(), "code".to_string()]), vec![Value::Text("a".to_string()), Value::Number("5".to_string())], None, ) .await .unwrap(); for n in ["b", "c"] { db.insert( "T".to_string(), Some(vec!["Name".to_string()]), vec![Value::Text(n.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "code".to_string(), Type::Serial, ChangeColumnMode::Default, None, ) .await .expect("int -> serial with auto-fill should succeed"); let note = result .client_side .expect("auto-fill should produce a client-side note"); assert_eq!(note.auto_filled, 2); assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial)); // Confirm the filled values: existing 5, fills are 6 // and 7 (continue sequence from MAX+1). let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let code_idx = data.columns.iter().position(|c| c == "code").unwrap(); let mut values: Vec = data .rows .iter() .filter_map(|r| r[code_idx].as_ref().and_then(|s| s.parse::().ok())) .collect(); values.sort(); assert_eq!(values, vec![5, 6, 7]); } #[tokio::test] async fn change_column_type_text_to_shortid_with_nulls_auto_fills() { // text → shortid is in the matrix; null cells in the // text column get fresh shortids (ADR-0018 §3). let db = db(); make_table_with_n_rows(&db, "T", 0).await; db.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::Text), None) .await .unwrap(); // One row with a valid shortid value, two with NULL. db.insert( "T".to_string(), Some(vec!["Name".to_string(), "tag".to_string()]), vec![ Value::Text("a".to_string()), Value::Text("23456789Ab".to_string()), ], None, ) .await .unwrap(); for n in ["b", "c"] { db.insert( "T".to_string(), Some(vec!["Name".to_string()]), vec![Value::Text(n.to_string())], None, ) .await .unwrap(); } let result = db .change_column_type( "T".to_string(), "tag".to_string(), Type::ShortId, ChangeColumnMode::Default, None, ) .await .expect("text -> shortid with auto-fill should succeed"); let note = result .client_side .expect("auto-fill should produce a client-side note"); assert_eq!(note.auto_filled, 2); assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId)); // All three rows now have valid shortids. let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid after fill"); assert!(v.len() >= 10 && v.len() <= 12, "len out of range: {v}"); } } #[tokio::test] async fn change_column_type_refuses_relationship_column() { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "cust_id".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None, ) .await .unwrap(); let err = db .change_column_type( "Orders".to_string(), "cust_id".to_string(), Type::Text, ChangeColumnMode::Default, None, ) .await .unwrap_err(); let DbError::Unsupported(msg) = &err else { panic!("expected Unsupported, got {err:?}"); }; // The refusal explains the FK link, not just that it failed // (ADR-0035 Amendment 1, gap D). assert!( msg.contains("uses it as a foreign key"), "explains the FK link; got: {msg}" ); } #[tokio::test] async fn change_column_type_no_op_to_same_type_errors() { let db = db(); make_id_table(&db, "T").await; db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Int), None) .await .unwrap(); let err = db .change_column_type("T".to_string(), "A".to_string(), Type::Int, ChangeColumnMode::Default, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn describe_missing_table_returns_no_such_table() { let db = db(); let err = db.describe_table("Ghost".to_string(), None).await.unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable), other => panic!("unexpected error: {other:?}"), } } // --- Relationship tests --- async fn customers_orders_setup(db: &Database) { db.create_table( "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], None) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) .await .unwrap(); } #[tokio::test] async fn add_relationship_appears_outbound_on_child() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); assert_eq!(orders.outbound_relationships.len(), 1); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.local_column, "CustId"); assert_eq!(rel.other_table, "Customers"); assert_eq!(rel.other_column, "id"); assert_eq!(rel.name, "Customers_id_to_Orders_CustId"); } #[tokio::test] async fn add_relationship_appears_inbound_on_parent() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); assert_eq!(customers.inbound_relationships.len(), 1); let rel = &customers.inbound_relationships[0]; assert_eq!(rel.local_column, "id"); assert_eq!(rel.other_table, "Orders"); assert_eq!(rel.other_column, "CustId"); } #[tokio::test] async fn add_relationship_with_user_supplied_name() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::Cascade, ReferentialAction::SetNull, false, None) .await .unwrap(); let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.name, "cust_orders"); assert_eq!(rel.on_delete, ReferentialAction::Cascade); assert_eq!(rel.on_update, ReferentialAction::SetNull); } #[tokio::test] async fn add_relationship_with_create_fk_creates_the_column() { let db = db(); // Create child *without* the FK column. db.create_table( "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, true, // --create-fk None, ) .await .unwrap(); let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); // The auto-created FK column has user_type Int (Serial's // fk_target_type), not Serial. let cust = orders .columns .iter() .find(|c| c.name == "CustId") .expect("FK column auto-created"); assert_eq!(cust.user_type, Some(Type::Int)); assert_eq!(orders.outbound_relationships.len(), 1); } #[tokio::test] async fn add_relationship_without_create_fk_errors_when_column_missing() { let db = db(); db.create_table( "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); let err = db .add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[tokio::test] async fn add_relationship_with_type_mismatch_errors_with_advice() { let db = db(); db.create_table( "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); // Wrong type — text instead of int. db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Text), None) .await .unwrap(); let err = db .add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap_err(); match err { DbError::Unsupported(msg) => { assert!(msg.contains("type mismatch"), "{msg}"); assert!(msg.contains("text") && msg.contains("int"), "{msg}"); } other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn add_relationship_against_non_pk_errors() { let db = db(); // Customers has a non-PK column we'll try to point at. db.create_table( "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], None) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); db.add_column("Orders".to_string(), ColumnSpec::new("CustName".to_string(), Type::Text), None) .await .unwrap(); let err = db .add_relationship( None, "Customers".to_string(), "Name".to_string(), "Orders".to_string(), "CustName".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap_err(); match err { DbError::Unsupported(msg) => assert!(msg.contains("not a primary key"), "{msg}"), other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn drop_relationship_by_name_clears_both_sides() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); db.drop_relationship(RelationshipSelector::Named { name: "cust_orders".to_string(), }, None) .await .unwrap(); let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); assert!(customers.inbound_relationships.is_empty()); } #[tokio::test] async fn drop_relationship_by_endpoints_works() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); db.drop_relationship(RelationshipSelector::Endpoints { parent_table: "Customers".to_string(), parent_column: "id".to_string(), child_table: "Orders".to_string(), child_column: "CustId".to_string(), }, None) .await .unwrap(); let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); } #[tokio::test] async fn drop_table_with_inbound_relationship_errors() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); let err = db.drop_table("Customers".to_string(), None).await.unwrap_err(); match err { DbError::Unsupported(msg) => { assert!(msg.contains("referenced by"), "{msg}"); } other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn drop_child_table_cleans_relationship_metadata() { let db = db(); customers_orders_setup(&db).await; db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); // Dropping the child is allowed (no inbound relationships // on Orders) and cleans the metadata. db.drop_table("Orders".to_string(), None).await.unwrap(); let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); assert!(customers.inbound_relationships.is_empty()); } #[tokio::test] async fn add_relationship_with_duplicate_name_errors() { let db = db(); customers_orders_setup(&db).await; db.add_column("Orders".to_string(), ColumnSpec::new("OtherCust".to_string(), Type::Int), None) .await .unwrap(); db.add_relationship( Some("dup".to_string()), "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); let err = db .add_relationship( Some("dup".to_string()), "Customers".to_string(), "id".to_string(), "Orders".to_string(), "OtherCust".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap_err(); match err { DbError::Unsupported(msg) => assert!(msg.contains("already exists"), "{msg}"), other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn rebuild_preserves_existing_data_through_relationship_change() { let db = db(); customers_orders_setup(&db).await; // Direct INSERT through the underlying connection isn't // exposed via the public API yet (C5). The point of this // test is to verify the rebuild itself works on populated // tables — we add a column with a default-able operation // and then add a relationship to ensure the table survives. db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::Cascade, ReferentialAction::NoAction, false, None) .await .unwrap(); // After the rebuild, the original columns are still // present with the right user types, and any extra // metadata (Name on Customers) survives. let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["id", "Name"]); let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap(); assert_eq!(name_col.user_type, Some(Type::Text)); } // --- Data operations (C5) --- async fn customers_table(db: &Database) { db.create_table( "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], None) .await .unwrap(); } #[tokio::test] async fn insert_short_form_skips_serial_column() { let db = db(); customers_table(&db).await; let result = db .insert( "Customers".to_string(), None, vec![Value::Text("Alice".to_string())], None) .await .unwrap(); assert_eq!(result.rows_affected, 1); // The InsertResult itself carries the just-inserted row. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alice".to_string())); let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Alice".to_string())); // id was auto-filled by SQLite. assert_eq!(data.rows[0][0], Some("1".to_string())); } #[tokio::test] async fn insert_short_form_auto_generates_shortid() { let db = db(); db.create_table( "Tags".to_string(), vec![col("id", Type::ShortId), col("Label", Type::Text)], vec!["id".to_string()], None) .await .unwrap(); db.insert( "Tags".to_string(), None, vec![Value::Text("database".to_string())], None) .await .unwrap(); let data = db.query_data("Tags".to_string(), None, None, None).await.unwrap(); let id = data.rows[0][0].as_ref().expect("auto-generated id"); assert!( id.len() >= 10 && id.len() <= 12, "expected a base58 shortid, got {id}" ); } #[tokio::test] async fn insert_explicit_columns_uses_user_values() { let db = db(); customers_table(&db).await; db.insert( "Customers".to_string(), Some(vec!["id".to_string(), "Name".to_string()]), vec![Value::Number("99".to_string()), Value::Text("Bob".to_string())], None) .await .unwrap(); let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows[0][0], Some("99".to_string())); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } #[tokio::test] async fn insert_with_type_mismatch_returns_invalid_value() { let db = db(); customers_table(&db).await; // 42 is a number, but Name expects text. let err = db .insert( "Customers".to_string(), Some(vec!["Name".to_string()]), vec![Value::Number("42".to_string())], None) .await .unwrap_err(); assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}"); } #[tokio::test] async fn update_with_where_affects_only_matching_rows() { let db = db(); customers_table(&db).await; for name in ["Alice", "Bob"] { db.insert( "Customers".to_string(), None, vec![Value::Text(name.to_string())], None) .await .unwrap(); } let result = db .update( "Customers".to_string(), vec![("Name".to_string(), Value::Text("Alicia".to_string()))], RowFilter::eq("id", Value::Number("1".to_string())), None) .await .unwrap(); assert_eq!(result.rows_affected, 1); // The UpdateResult contains only the updated rows. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alicia".to_string())); let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("Alicia".to_string())); assert_eq!(data.rows[1][1], Some("Bob".to_string())); } // ---- Complex WHERE expressions (ADR-0026) ---------------- /// `People(id serial pk, Name text, Age int, Active bool)` /// seeded with four rows: Alice/25/true, Bob/35/false, /// Carol/45/true, Dave/35/true (ids 1..4). async fn people_table(db: &Database) { db.create_table( "People".to_string(), vec![ col("id", Type::Serial), col("Name", Type::Text), col("Age", Type::Int), col("Active", Type::Bool), ], vec!["id".to_string()], None, ) .await .unwrap(); for (name, age, active) in [ ("Alice", 25, true), ("Bob", 35, false), ("Carol", 45, true), ("Dave", 35, true), ] { db.insert( "People".to_string(), None, vec![ Value::Text(name.to_string()), Value::Number(age.to_string()), Value::Bool(active), ], None, ) .await .unwrap(); } } /// Pull the `RowFilter` out of an `update` / `delete` parsed /// from DSL — the readable way to build a complex `Expr`. /// Parses in Simple mode: `update`/`delete` are shared entry /// words since sub-phase 3j (ADR-0033 Amendment 3), so the DSL /// `Command::Update`/`Delete` is only produced in Simple mode. fn parse_filter(dsl: &str) -> RowFilter { match crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple) .expect("filter parse") { crate::dsl::command::Command::Update { filter, .. } | crate::dsl::command::Command::Delete { filter, .. } => filter, other => panic!("expected update/delete, got {other:?}"), } } /// Pull the optional filter + limit out of a parsed /// `show data` command. fn parse_show(dsl: &str) -> (Option, Option) { match crate::dsl::parser::parse_command(dsl).expect("show parse") { crate::dsl::command::Command::ShowData { filter, limit, .. } => { (filter, limit) } other => panic!("expected show data, got {other:?}"), } } /// The `Name` column of every remaining row, in row order. fn names(data: &DataResult) -> Vec { data.rows .iter() .map(|r| r[1].clone().unwrap_or_default()) .collect() } #[tokio::test] async fn delete_with_and_expression_filters_rows() { let db = db(); people_table(&db).await; let result = db .delete( "People".to_string(), parse_filter( "delete from People where Age >= 35 and Active = true", ), None, ) .await .unwrap(); // Carol (45/true) and Dave (35/true) match; Bob (35) is // inactive, Alice (25) is too young. assert_eq!(result.rows_affected, 2); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Bob"]); } #[tokio::test] async fn delete_with_or_expression_filters_rows() { let db = db(); people_table(&db).await; let result = db .delete( "People".to_string(), parse_filter("delete from People where id = 1 or id = 3"), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 2); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } #[tokio::test] async fn delete_with_not_expression_filters_rows() { let db = db(); people_table(&db).await; // `not Age = 35` keeps Bob and Dave (both 35). let result = db .delete( "People".to_string(), parse_filter("delete from People where not Age = 35"), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 2); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } #[tokio::test] async fn delete_with_between_filters_rows() { let db = db(); people_table(&db).await; let result = db .delete( "People".to_string(), parse_filter("delete from People where Age between 30 and 40"), None, ) .await .unwrap(); // Bob (35) and Dave (35) are in range. assert_eq!(result.rows_affected, 2); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol"]); } #[tokio::test] async fn delete_with_in_filters_rows() { let db = db(); people_table(&db).await; let result = db .delete( "People".to_string(), parse_filter( "delete from People where Name in ('Alice', 'Carol')", ), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 2); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } #[tokio::test] async fn delete_with_like_filters_rows() { let db = db(); people_table(&db).await; let result = db .delete( "People".to_string(), parse_filter("delete from People where Name like 'A%'"), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 1, "only Alice matches `A%`"); let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]); } #[tokio::test] async fn update_with_complex_where_updates_only_matching_rows() { let db = db(); people_table(&db).await; // Deactivate everyone over 30 who is still active. let result = db .update( "People".to_string(), vec![("Active".to_string(), Value::Bool(false))], parse_filter( "update People set Active=false \ where Age > 30 and Active = true", ), None, ) .await .unwrap(); // Carol (45) and Dave (35) — Bob is already inactive. assert_eq!(result.rows_affected, 2); } #[tokio::test] async fn query_data_with_where_filters_rows() { let db = db(); people_table(&db).await; let (filter, limit) = parse_show("show data People where Active = true"); let data = db .query_data("People".to_string(), filter, limit, None) .await .unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]); } #[tokio::test] async fn query_data_with_limit_caps_rows_by_primary_key() { let db = db(); people_table(&db).await; let (filter, limit) = parse_show("show data People limit 2"); let data = db .query_data("People".to_string(), filter, limit, None) .await .unwrap(); // `limit` implies an ORDER BY the primary key, so this // is a stable "first 2 by id". assert_eq!(names(&data), vec!["Alice", "Bob"]); } #[tokio::test] async fn query_data_with_where_and_limit_combines_both() { let db = db(); people_table(&db).await; let (filter, limit) = parse_show("show data People where Age >= 35 limit 1"); let data = db .query_data("People".to_string(), filter, limit, None) .await .unwrap(); // Three rows match `Age >= 35` (Bob, Carol, Dave); the // limit keeps the first by primary key — Bob (id 2). assert_eq!(names(&data), vec!["Bob"]); } // --- explain / query plans (ADR-0028) ------------------- /// Parse a non-`explain` query command for use as the inner /// command of `explain_query_plan`. Simple mode: `explain` /// wraps the DSL `show data` / `update` / `delete` commands /// (ADR-0028; SQL DML is not explainable, ADR-0030 §13 OOS-2), /// and `update`/`delete` only yield the DSL variant in Simple /// mode (shared entry words since sub-phase 3j). fn parse_inner(dsl: &str) -> Command { crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple) .expect("inner command parse") } #[tokio::test] async fn explain_show_data_returns_a_scan_plan() { let db = db(); people_table(&db).await; let plan = db .explain_query_plan(parse_inner("show data People where Active = true")) .await .unwrap(); assert!(!plan.rows.is_empty(), "a plan has at least one node"); // No index on `Active`, so the filtered query is a full // table scan. assert!( plan.rows.iter().any(|r| r.detail.contains("SCAN")), "expected a SCAN node, got {:?}", plan.rows, ); } #[tokio::test] async fn explain_show_data_uses_an_index_when_one_exists() { let db = db(); people_table(&db).await; db.add_index(None, "People".to_string(), vec!["Age".to_string()], None) .await .unwrap(); let plan = db .explain_query_plan(parse_inner("show data People where Age = 35")) .await .unwrap(); // The teaching payoff: the plan flips from a scan to an // index search once an index covers the WHERE column. assert!( plan.rows.iter().any(|r| r.detail.contains("USING INDEX")), "expected an index search, got {:?}", plan.rows, ); } #[tokio::test] async fn explain_delete_does_not_remove_any_rows() { let db = db(); people_table(&db).await; let plan = db .explain_query_plan(parse_inner("delete from People where Active = true")) .await .unwrap(); assert!(!plan.rows.is_empty()); // ADR-0028 §1: EXPLAIN QUERY PLAN never executes. let data = db .query_data("People".to_string(), None, None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain delete must not delete"); } #[tokio::test] async fn explain_update_does_not_change_any_data() { let db = db(); people_table(&db).await; db.explain_query_plan(parse_inner( "update People set Active = false where Active = true", )) .await .unwrap(); let (filter, _) = parse_show("show data People where Active = true"); let data = db .query_data("People".to_string(), filter, None, None) .await .unwrap(); // Alice, Carol, Dave are still active — nothing ran. assert_eq!(data.rows.len(), 3, "explain update must not write"); } #[tokio::test] async fn explain_display_sql_inlines_literals_and_quotes_idents() { let db = db(); people_table(&db).await; let plan = db .explain_query_plan(parse_inner("show data People where Name = 'Alice'")) .await .unwrap(); assert!( plan.display_sql.contains("\"People\""), "table identifier should be double-quoted: {}", plan.display_sql, ); assert!( plan.display_sql.contains("'Alice'"), "WHERE literal should be inlined: {}", plan.display_sql, ); assert!( !plan.display_sql.contains('?'), "display SQL must carry no `?` placeholders: {}", plan.display_sql, ); } #[tokio::test] async fn explain_display_sql_shows_the_implicit_order_by_for_limit() { let db = db(); people_table(&db).await; let plan = db .explain_query_plan(parse_inner("show data People limit 2")) .await .unwrap(); // `limit` adds an implicit ORDER BY the primary key // (ADR-0026 §5) — the display SQL surfaces it. assert!( plan.display_sql.contains("ORDER BY"), "limit implies ORDER BY pk: {}", plan.display_sql, ); assert!( plan.display_sql.contains("LIMIT 2"), "limit count inlined: {}", plan.display_sql, ); } #[tokio::test] async fn explain_display_sql_writes_inequality_as_angle_brackets() { let db = db(); people_table(&db).await; let plan = db .explain_query_plan(parse_inner("show data People where Age != 35")) .await .unwrap(); // ADR-0028 §3: inequality is shown as standard SQL `<>` // even when the user typed `!=`. assert!( plan.display_sql.contains("<>"), "inequality should render as `<>`: {}", plan.display_sql, ); assert!( !plan.display_sql.contains("!="), "the `!=` spelling should not survive: {}", plan.display_sql, ); } #[tokio::test] async fn explain_of_a_missing_table_is_an_error() { let db = db(); let result = db .explain_query_plan(parse_inner("show data NoSuchTable")) .await; assert!(result.is_err(), "explaining a missing table should fail"); } // --- column constraints at create-table (ADR-0029) ------ /// A `ColumnSpec` carrying the four constraint slots. fn col_c( name: &str, ty: Type, not_null: bool, unique: bool, default: Option, ) -> ColumnSpec { ColumnSpec { name: name.to_string(), ty, not_null, unique, default, check: None, default_sql: None, check_sql: None, } } /// Create `T(id serial pk, )`. async fn table_with(db: &Database, extra: ColumnSpec) -> TableDescription { db.create_table( "T".to_string(), vec![col("id", Type::Serial), extra], vec!["id".to_string()], None, ) .await .expect("create table") } #[tokio::test] async fn create_table_not_null_column_rejects_a_null_insert() { let db = db(); table_with(&db, col_c("name", Type::Text, true, false, None)).await; let ok = db .insert( "T".to_string(), Some(vec!["name".to_string()]), vec![Value::Text("Alice".to_string())], None, ) .await; assert!(ok.is_ok(), "a non-null value is accepted"); let bad = db .insert( "T".to_string(), Some(vec!["name".to_string()]), vec![Value::Null], None, ) .await; assert!(bad.is_err(), "NULL into a NOT NULL column is refused"); } #[tokio::test] async fn create_table_unique_column_rejects_a_duplicate_insert() { let db = db(); table_with(&db, col_c("email", Type::Text, false, true, None)).await; let insert_email = |v: &str| { db.insert( "T".to_string(), Some(vec!["email".to_string()]), vec![Value::Text(v.to_string())], None, ) }; assert!(insert_email("a@x.io").await.is_ok()); assert!( insert_email("a@x.io").await.is_err(), "a duplicate value violates UNIQUE", ); assert!( insert_email("b@x.io").await.is_ok(), "a distinct value is still accepted", ); } #[tokio::test] async fn create_table_default_applies_when_the_column_is_omitted() { let db = db(); db.create_table( "T".to_string(), vec![ col("id", Type::Serial), col("name", Type::Text), col_c("tier", Type::Int, false, false, Some(Value::Number("3".to_string()))), ], vec!["id".to_string()], None, ) .await .unwrap(); // Insert names only `name` — `tier` is omitted and so // takes its DEFAULT; `id` auto-fills as a serial. db.insert( "T".to_string(), Some(vec!["name".to_string()]), vec![Value::Text("Alice".to_string())], None, ) .await .unwrap(); let data = db .query_data("T".to_string(), None, None, None) .await .unwrap(); let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap(); assert_eq!( data.rows[0][tier_idx].as_deref(), Some("3"), "the omitted column took its DEFAULT", ); } #[tokio::test] async fn describe_surfaces_the_column_constraints() { let db = db(); let desc = table_with( &db, col_c( "email", Type::Text, true, true, Some(Value::Text("none".to_string())), ), ) .await; let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); assert!(email.notnull, "NOT NULL is described"); assert!(email.unique, "UNIQUE is described"); assert_eq!( email.default.as_deref(), Some("'none'"), "the DEFAULT literal is described", ); } #[tokio::test] async fn rebuild_preserves_column_constraints() { // A type change on one column rebuilds the whole table // through `schema_to_ddl`; the constraints on the other // columns must survive that round-trip. let db = db(); db.create_table( "T".to_string(), vec![ col("id", Type::Serial), col_c("email", Type::Text, true, true, None), col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))), ], vec!["id".to_string()], None, ) .await .unwrap(); // Change `tier`'s type — int -> decimal — forcing a rebuild. db.change_column_type( "T".to_string(), "tier".to_string(), Type::Decimal, ChangeColumnMode::Default, None, ) .await .unwrap(); let desc = db.describe_table("T".to_string(), None).await.unwrap(); let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE"); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); assert_eq!( tier.default.as_deref(), Some("1"), "tier keeps its DEFAULT across the rebuild", ); } // --- column constraints at add-column (ADR-0029 §6) ----- #[tokio::test] async fn add_column_with_default_fills_existing_rows() { let db = db(); people_table(&db).await; // 4 rows db.add_column( "People".to_string(), col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))), None, ) .await .unwrap(); let data = db .query_data("People".to_string(), None, None, None) .await .unwrap(); let idx = data.columns.iter().position(|c| c == "tier").unwrap(); assert!( data.rows.iter().all(|r| r[idx].as_deref() == Some("1")), "every existing row took the new column's DEFAULT", ); } #[tokio::test] async fn add_not_null_column_without_default_to_populated_table_is_refused() { let db = db(); people_table(&db).await; let result = db .add_column( "People".to_string(), col_c("x", Type::Int, true, false, None), None, ) .await; assert!( result.is_err(), "a NOT NULL column with no default cannot be added to a table with rows", ); } #[tokio::test] async fn add_not_null_column_without_default_to_empty_table_succeeds() { let db = db(); db.create_table( "T".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None, ) .await .unwrap(); db.add_column( "T".to_string(), col_c("x", Type::Int, true, false, None), None, ) .await .expect("NOT NULL with no default is fine on an empty table"); let desc = db.describe_table("T".to_string(), None).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull); } #[tokio::test] async fn add_not_null_column_with_a_default_succeeds_on_a_populated_table() { let db = db(); people_table(&db).await; db.add_column( "People".to_string(), col_c("tier", Type::Int, true, false, Some(Value::Number("0".to_string()))), None, ) .await .unwrap(); let desc = db.describe_table("People".to_string(), None).await.unwrap(); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); assert!(tier.notnull); assert_eq!(tier.default.as_deref(), Some("0")); } #[tokio::test] async fn add_unique_column_applies_the_constraint_via_rebuild() { let db = db(); people_table(&db).await; // 4 rows; the new column is all-NULL db.add_column( "People".to_string(), col_c("badge", Type::Text, false, true, None), None, ) .await .expect("a UNIQUE column with no default is fine — NULLs do not collide"); let desc = db.describe_table("People".to_string(), None).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique); } #[tokio::test] async fn add_unique_column_with_default_to_a_multi_row_table_is_refused() { let db = db(); people_table(&db).await; // 4 rows let result = db .add_column( "People".to_string(), col_c("badge", Type::Text, false, true, Some(Value::Text("X".to_string()))), None, ) .await; assert!( result.is_err(), "a UNIQUE column with a default would give every row the same value", ); } #[tokio::test] async fn add_serial_column_with_a_default_is_refused() { let db = db(); people_table(&db).await; let result = db .add_column( "People".to_string(), col_c("seq", Type::Serial, false, false, Some(Value::Number("1".to_string()))), None, ) .await; assert!( result.is_err(), "a serial column auto-fills its own values — a default is ambiguous", ); } // --- CHECK constraints (ADR-0029 §4) -------------------- /// Parse a `create table` DSL string into its db-call parts /// — the way to get a real `Expr` into a `ColumnSpec.check` /// without hand-building the AST. fn parse_create(dsl: &str) -> (String, Vec, Vec) { match crate::dsl::parser::parse_command(dsl).expect("create table parse") { Command::CreateTable { name, columns, primary_key, } => (name, columns, primary_key), other => panic!("expected CreateTable, got {other:?}"), } } /// A `ColumnSpec` carrying a `CHECK`, parsed from DSL. fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec { let (_, columns, _) = parse_create(&format!( "create table __probe with pk {name}({}) check ({check_dsl})", ty.keyword(), )); columns.into_iter().next().expect("one column") } #[tokio::test] async fn create_table_check_constraint_is_enforced() { let db = db(); let (n, c, pk) = parse_create( "create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))", ); db.create_table(n, c, pk, None).await.unwrap(); let insert_grade = |g: &str| { db.insert( "Grades".to_string(), Some(vec!["grade".to_string()]), vec![Value::Text(g.to_string())], None, ) }; assert!(insert_grade("A").await.is_ok(), "a value the check allows"); assert!( insert_grade("Z").await.is_err(), "a value the check forbids is refused", ); } #[tokio::test] async fn describe_surfaces_the_check_constraint() { let db = db(); let (n, c, pk) = parse_create("create table T with pk age(int) check (age >= 0)"); db.create_table(n, c, pk, None).await.unwrap(); let desc = db.describe_table("T".to_string(), None).await.unwrap(); let age = desc.columns.iter().find(|c| c.name == "age").unwrap(); let check = age.check.as_deref().expect("age carries a CHECK"); assert!( check.contains(">="), "the compiled check SQL is surfaced: {check}", ); } #[tokio::test] async fn add_column_check_constraint_is_enforced() { let db = db(); people_table(&db).await; db.add_column( "People".to_string(), col_c_check("score", Type::Int, "score >= 0"), None, ) .await .expect("a CHECK column adds via the rebuild path"); let desc = db.describe_table("People".to_string(), None).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some()); // An update that violates the check is refused. let bad = db .update( "People".to_string(), vec![("score".to_string(), Value::Number("-1".to_string()))], parse_filter("update People set score=-1 where id = 1"), None, ) .await; assert!(bad.is_err(), "an update violating the CHECK is refused"); } #[tokio::test] async fn rebuild_preserves_a_check_constraint() { let db = db(); let (n, c, pk) = parse_create("create table T with pk code(text) check (code like 'X%')"); db.create_table(n, c, pk, None).await.unwrap(); db.add_column("T".to_string(), col("note", Type::Int), None) .await .unwrap(); // A type change on `note` rebuilds the table; `code`'s // CHECK must survive the round-trip through schema_to_ddl. db.change_column_type( "T".to_string(), "note".to_string(), Type::Decimal, ChangeColumnMode::Default, None, ) .await .unwrap(); let desc = db.describe_table("T".to_string(), None).await.unwrap(); assert!( desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(), "code keeps its CHECK across the rebuild", ); } #[tokio::test] async fn add_serial_column_with_a_check_is_refused() { let db = db(); people_table(&db).await; let result = db .add_column( "People".to_string(), col_c_check("seq", Type::Serial, "seq > 0"), None, ) .await; assert!( result.is_err(), "a CHECK on an auto-generated column is a create-table-only feature", ); } #[tokio::test] async fn update_with_all_rows_affects_everything() { let db = db(); customers_table(&db).await; for name in ["Alice", "Bob", "Carol"] { db.insert( "Customers".to_string(), None, vec![Value::Text(name.to_string())], None) .await .unwrap(); } let result = db .update( "Customers".to_string(), vec![("Name".to_string(), Value::Text("X".to_string()))], RowFilter::AllRows, None) .await .unwrap(); assert_eq!(result.rows_affected, 3); assert_eq!(result.data.rows.len(), 3); } #[tokio::test] async fn delete_with_where_removes_matching_rows() { let db = db(); customers_table(&db).await; for name in ["Alice", "Bob"] { db.insert( "Customers".to_string(), None, vec![Value::Text(name.to_string())], None) .await .unwrap(); } let result = db .delete( "Customers".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), None) .await .unwrap(); assert_eq!(result.rows_affected, 1); assert!(result.cascade.is_empty(), "no children to cascade to"); let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } #[tokio::test] async fn fk_violation_returns_engine_classified_constraint_error() { // Pre-H1 (ADR-0019), this test asserted that the // engine's `FOREIGN KEY constraint failed` text was // enriched in-band with a list of every relevant FK on // the offending table. That enrichment moved into the // friendly-error layer's catalog wording (see // `friendly::translate::tests::fk_with_*_op_renders_*`) // and out of the raw `DbError::Sqlite { message }` // payload. What stays in `message` is the engine's // un-enriched text — useful for the translator's own // classification, but no longer the user-facing // surface. // // This test now just confirms the engine error reaches // us classified as a constraint violation; user-facing // wording is exercised in the friendly module. let db = db(); customers_table(&db).await; db.create_table( "Orders".to_string(), vec![col("id", Type::Serial), col("CustId", Type::Int)], vec!["id".to_string()], None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::NoAction, ReferentialAction::NoAction, false, None) .await .unwrap(); let err = db .insert( "Orders".to_string(), Some(vec!["CustId".to_string()]), vec![Value::Number("999".to_string())], None) .await .unwrap_err(); match err { DbError::Sqlite { message, .. } => { assert!( message.to_ascii_lowercase().contains("foreign key"), "expected engine-classified FK message, got: {message}" ); } other => panic!("unexpected error: {other:?}"), } } #[tokio::test] async fn cascade_delete_propagates_to_children() { let db = db(); customers_table(&db).await; db.create_table( "Orders".to_string(), vec![col("id", Type::Serial), col("CustId", Type::Int)], vec!["id".to_string()], None) .await .unwrap(); db.add_relationship( None, "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), ReferentialAction::Cascade, ReferentialAction::NoAction, false, None) .await .unwrap(); db.insert( "Customers".to_string(), None, vec![Value::Text("Alice".to_string())], None) .await .unwrap(); db.insert( "Orders".to_string(), Some(vec!["CustId".to_string()]), vec![Value::Number("1".to_string())], None) .await .unwrap(); // Delete Alice — cascades to Orders. db.delete( "Customers".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), None) .await .unwrap(); let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap(); assert!(orders.rows.is_empty(), "child rows should be cascaded"); } #[tokio::test] async fn self_referential_cascade_counts_only_cascaded_rows() { // A self-referential ON DELETE CASCADE FK (T.ParentId -> T.id): // deleting the root of a chain cascades down within T. The // directly-deleted root is reported in `rows_affected`, so the // cascade summary must report only the *additional* rows // removed by the self-reference — the raw before/after diff // would double-count the direct delete. Parity fix shared with // `do_sql_delete` (ADR-0033 Amendment 2 mechanism). let db = db(); db.create_table( "T".to_string(), vec![col("id", Type::Int), col("ParentId", Type::Int)], vec!["id".to_string()], None, ) .await .unwrap(); db.add_relationship( Some("parent_of".to_string()), "T".to_string(), "id".to_string(), "T".to_string(), "ParentId".to_string(), ReferentialAction::Cascade, ReferentialAction::NoAction, false, None, ) .await .unwrap(); // Chain: 1 (root) <- 2 <- 3. db.insert( "T".to_string(), Some(vec!["id".to_string(), "ParentId".to_string()]), vec![Value::Number("1".to_string()), Value::Null], None, ) .await .unwrap(); db.insert( "T".to_string(), Some(vec!["id".to_string(), "ParentId".to_string()]), vec![Value::Number("2".to_string()), Value::Number("1".to_string())], None, ) .await .unwrap(); db.insert( "T".to_string(), Some(vec!["id".to_string(), "ParentId".to_string()]), vec![Value::Number("3".to_string()), Value::Number("2".to_string())], None, ) .await .unwrap(); let result = db .delete( "T".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 1, "one row matched the filter directly"); assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once"); assert_eq!( result.cascade[0].rows_changed, 2, "only the 2 cascaded rows, not the directly-deleted root too" ); } #[tokio::test] async fn query_data_renders_bools_as_words() { let db = db(); db.create_table( "Flags".to_string(), vec![col("id", Type::Serial), col("Active", Type::Bool)], vec!["id".to_string()], None) .await .unwrap(); db.insert( "Flags".to_string(), None, vec![Value::Bool(true)], None) .await .unwrap(); db.insert( "Flags".to_string(), None, vec![Value::Bool(false)], None) .await .unwrap(); let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("true".to_string())); assert_eq!(data.rows[1][1], Some("false".to_string())); } #[tokio::test] async fn query_data_renders_null_as_none() { let db = db(); db.create_table( "T".to_string(), vec![col("id", Type::Serial), col("note", Type::Text)], vec!["id".to_string()], None) .await .unwrap(); db.insert( "T".to_string(), None, vec![Value::Null], None) .await .unwrap(); let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); assert_eq!(data.rows[0][1], None); } // ---- ADR-0018 UNIQUE infrastructure ---- #[test] fn read_schema_detects_single_column_unique() { // Create a raw connection (no Database wrapper) so we // can drop arbitrary DDL containing UNIQUE — the DSL // doesn't yet expose UNIQUE as a user-controlled // constraint (C3-track). let conn = Connection::open_in_memory().unwrap(); configure_connection(&conn).unwrap(); conn.execute_batch( "CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;", ) .unwrap(); let schema = read_schema(&conn, "T").unwrap(); let id = schema.columns.iter().find(|c| c.name == "id").unwrap(); let code = schema.columns.iter().find(|c| c.name == "code").unwrap(); assert!(id.primary_key, "id should still be PK"); assert!( !id.unique, "PK columns are not separately marked unique \ (PK already implies it; double-marking would lead to \ redundant DDL)" ); assert!(code.unique, "code should be detected as unique"); } #[test] fn read_schema_ignores_compound_unique_constraints() { // Multi-column UNIQUE is out of scope for ADR-0018; the // detection helper filters it out so schema_to_ddl does // not accidentally emit a column-level UNIQUE for one // half of a compound constraint. let conn = Connection::open_in_memory().unwrap(); configure_connection(&conn).unwrap(); conn.execute_batch( "CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT, UNIQUE(a, b)) STRICT;", ) .unwrap(); let schema = read_schema(&conn, "T").unwrap(); for col in &schema.columns { assert!( !col.unique, "column {} should not be marked unique under compound UNIQUE", col.name ); } } #[test] fn schema_to_ddl_emits_unique_for_marked_column() { // Construct a ReadSchema with a unique non-PK column and // confirm the round-trip through schema_to_ddl produces // valid DDL containing UNIQUE. let schema = ReadSchema { columns: vec![ ReadColumn { name: "id".to_string(), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, unique: false, default_sql: None, check: None, user_type: Some(Type::Serial), }, ReadColumn { name: "code".to_string(), sqlite_type: "TEXT".to_string(), notnull: false, primary_key: false, unique: true, default_sql: None, check: None, user_type: Some(Type::Text), }, ], primary_key: vec!["id".to_string()], foreign_keys: vec![], unique_constraints: Vec::new(), check_constraints: Vec::new(), }; let ddl = schema_to_ddl("T", &schema); assert!( ddl.contains("\"id\" INTEGER PRIMARY KEY"), "PK emission preserved: {ddl}" ); assert!( ddl.contains("\"code\" TEXT UNIQUE"), "UNIQUE emitted on non-PK column: {ddl}" ); assert!( !ddl.contains("\"id\" INTEGER PRIMARY KEY UNIQUE"), "PK column should not double-emit UNIQUE: {ddl}" ); // The DDL is valid SQL — apply it and read it back. let conn = Connection::open_in_memory().unwrap(); configure_connection(&conn).unwrap(); conn.execute_batch(&ddl).unwrap(); let round = read_schema(&conn, "T").unwrap(); let code = round.columns.iter().find(|c| c.name == "code").unwrap(); assert!(code.unique, "round-tripped column retains unique flag"); } #[tokio::test] async fn unique_flag_persisted_in_yaml_for_non_pk_serial() { // ADR-0018 §4: a non-PK serial column gains UNIQUE, // and the flag must be recorded in `project.yaml` so // that a rebuild-from-text reconstructs the constraint // faithfully (preserves the "serial -> int leaves // UNIQUE in place" promise across save/load cycles). use crate::persistence::Persistence; let dir = tempfile::tempdir().unwrap(); let persistence = Persistence::new(dir.path().to_path_buf()); let db_path = dir.path().join("playground.db"); let db = Database::open_with_persistence(&db_path, persistence).unwrap(); db.create_table( "T".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], None, ) .await .unwrap(); db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Read the persisted YAML straight from disk. let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap(); assert!( yaml.contains("seq") && yaml.contains("unique: true"), "yaml should record the unique flag for `seq`: {yaml}" ); // PK column not separately marked unique — PK already // implies UNIQUE, double-marking would emit redundant // DDL on rebuild. let lines_with_id: Vec<&str> = yaml .lines() .filter(|l| l.contains("name: id")) .collect(); for line in &lines_with_id { assert!( !line.contains("unique: true"), "PK column should not be marked unique in YAML: {line}" ); } } #[tokio::test] async fn unique_flag_round_trips_through_rebuild() { // End-to-end: create a table with a non-PK serial, // then rebuild from text. The rebuild should re-emit // UNIQUE on the column, restoring the constraint // exactly as the original. use crate::persistence::Persistence; let dir = tempfile::tempdir().unwrap(); let persistence = Persistence::new(dir.path().to_path_buf()); let db_path = dir.path().join("playground.db"); let db = Database::open_with_persistence(&db_path, persistence).unwrap(); db.create_table( "T".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], None, ) .await .unwrap(); db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) .await .unwrap(); // Tear down the .db file and rebuild from yaml + csvs. drop(db); std::fs::remove_file(&db_path).unwrap(); let persistence2 = Persistence::new(dir.path().to_path_buf()); let db = Database::open_with_persistence(&db_path, persistence2).unwrap(); db.rebuild_from_text(dir.path().to_path_buf(), None) .await .unwrap(); // Read the schema and confirm `seq` is still unique. let desc = db.describe_table("T".to_string(), None).await.unwrap(); let seq = desc .columns .iter() .find(|c| c.name == "seq") .expect("seq column rebuilt"); assert_eq!(seq.user_type, Some(Type::Serial)); // Confirm the UNIQUE constraint is enforced post- // rebuild by attempting to UPDATE all rows to the same // value... but the table is empty. Instead, insert two // rows and then UPDATE. for n in ["a", "b"] { db.insert( "T".to_string(), Some(vec!["Name".to_string()]), vec![Value::Text(n.to_string())], None, ) .await .unwrap(); } let err = db .update( "T".to_string(), vec![("seq".to_string(), Value::Number("1".to_string()))], RowFilter::AllRows, None, ) .await .unwrap_err(); match err { DbError::Sqlite { kind, .. } => { assert_eq!(kind, SqliteErrorKind::UniqueViolation); } other => panic!("unexpected: {other:?}"), } } #[tokio::test] async fn quoted_table_names_round_trip() { let db = db(); // Identifier with internal whitespace would not parse via the DSL // today, but the DB layer should still handle it correctly. db.create_table( "Order Lines".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None) .await .unwrap(); let tables = db.list_tables().await.unwrap(); assert_eq!(tables, vec!["Order Lines".to_string()]); let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap(); assert_eq!(desc.name, "Order Lines"); } // ---- list_names_for (ADR-0022 §9, stage 7) ---- #[tokio::test] async fn list_names_for_new_name_short_circuits_to_empty() { // The user invents new names; schema has nothing to // offer. The public method short-circuits before // touching the worker. let db = db(); let names = db .list_names_for(crate::dsl::grammar::IdentSource::NewName) .await .unwrap(); assert!(names.is_empty()); } #[tokio::test] async fn list_names_for_table_returns_user_tables_alphabetised() { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; let names = db .list_names_for(crate::dsl::grammar::IdentSource::Tables) .await .unwrap(); assert_eq!(names, vec!["Customers".to_string(), "Orders".to_string()]); } #[tokio::test] async fn list_names_for_table_filters_internal_metadata_tables() { // The internal __rdbms_* tables must never appear in // the completion menu (ADR-0002 user-facing posture). let db = db(); make_id_table(&db, "Customers").await; let names = db .list_names_for(crate::dsl::grammar::IdentSource::Tables) .await .unwrap(); assert_eq!(names, vec!["Customers".to_string()]); for n in &names { assert!( !n.starts_with("__rdbms_"), "internal table leaked into completion: {n}", ); } } #[tokio::test] async fn list_names_for_column_returns_distinct_columns_alphabetised() { let db = db(); // Two tables sharing a column name `id`, with different // additional columns. db.create_table( "Customers".to_string(), vec![col("id", Type::Serial), col("name", Type::Text)], vec!["id".to_string()], None, ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial), col("total", Type::Real)], vec!["id".to_string()], None, ) .await .unwrap(); let names = db .list_names_for(crate::dsl::grammar::IdentSource::Columns) .await .unwrap(); // `id` appears once despite being in both tables (DISTINCT). assert_eq!( names, vec!["id".to_string(), "name".to_string(), "total".to_string()], ); } #[tokio::test] async fn list_names_for_relationship_returns_named_relationships() { let db = db(); db.create_table( "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], None, ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial), col("CustId", Type::Int)], vec!["id".to_string()], None, ) .await .unwrap(); db.add_relationship( Some("cust_orders".to_string()), "Customers".to_string(), "id".to_string(), "Orders".to_string(), "CustId".to_string(), crate::dsl::ReferentialAction::NoAction, crate::dsl::ReferentialAction::NoAction, false, None, ) .await .unwrap(); let names = db .list_names_for(crate::dsl::grammar::IdentSource::Relationships) .await .unwrap(); assert_eq!(names, vec!["cust_orders".to_string()]); } // --- add constraint / drop constraint (ADR-0029 §2.2) ----- /// A `Constraint::Check` whose expression references /// `column` of type `ty`, parsed from `check_dsl`. fn check_constraint(column: &str, ty: Type, check_dsl: &str) -> Constraint { Constraint::Check( col_c_check(column, ty, check_dsl) .check .expect("the CHECK expression parses"), ) } /// `People` plus an all-NULL plain `int` column `x`. async fn people_with_null_column(db: &Database) { people_table(db).await; db.add_column("People".to_string(), col("x", Type::Int), None) .await .expect("a plain nullable column adds"); } #[tokio::test] async fn add_constraint_not_null_succeeds_on_clean_column() { let db = db(); people_table(&db).await; // every row has a non-null Name let desc = db .add_constraint( "People".to_string(), "Name".to_string(), Constraint::NotNull, None, ) .await .expect("NOT NULL applies — no row holds a null"); assert!( desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, "the column is now NOT NULL", ); } #[tokio::test] async fn add_constraint_not_null_refused_when_rows_are_null() { let db = db(); people_with_null_column(&db).await; // `x` is null in every row let result = db .add_constraint("People".to_string(), "x".to_string(), Constraint::NotNull, None) .await; let err = result.expect_err("NOT NULL is refused — rows hold null"); assert!( format!("{err}").contains("null"), "the refusal explains the null rows: {err}", ); } #[tokio::test] async fn add_constraint_unique_succeeds_on_distinct_values() { let db = db(); people_table(&db).await; // Names are all distinct let desc = db .add_constraint( "People".to_string(), "Name".to_string(), Constraint::Unique, None, ) .await .expect("UNIQUE applies — every Name is distinct"); assert!(desc.columns.iter().find(|c| c.name == "Name").unwrap().unique); } #[tokio::test] async fn add_constraint_unique_refused_on_duplicate_values() { let db = db(); people_table(&db).await; // Age 35 appears for Bob and Dave let result = db .add_constraint("People".to_string(), "Age".to_string(), Constraint::Unique, None) .await; assert!( result.is_err(), "UNIQUE is refused — the value 35 appears in two rows", ); } #[tokio::test] async fn add_constraint_check_succeeds_when_data_satisfies_it() { let db = db(); people_table(&db).await; // every Age is >= 0 let desc = db .add_constraint( "People".to_string(), "Age".to_string(), check_constraint("Age", Type::Int, "Age >= 0"), None, ) .await .expect("the CHECK applies — all rows satisfy it"); assert!(desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_some()); } #[tokio::test] async fn add_constraint_check_refused_when_rows_violate_it() { let db = db(); people_table(&db).await; // Alice is 25, Bob/Dave are 35 let result = db .add_constraint( "People".to_string(), "Age".to_string(), check_constraint("Age", Type::Int, "Age >= 40"), None, ) .await; assert!(result.is_err(), "the CHECK is refused — three rows are under 40"); } #[tokio::test] async fn add_constraint_default_succeeds() { let db = db(); people_table(&db).await; let desc = db .add_constraint( "People".to_string(), "Age".to_string(), Constraint::Default(Value::Number("0".to_string())), None, ) .await .expect("a DEFAULT applies — it never touches existing rows"); assert_eq!( desc.columns.iter().find(|c| c.name == "Age").unwrap().default.as_deref(), Some("0"), ); } #[tokio::test] async fn add_constraint_check_is_enforced_after_being_added() { let db = db(); people_table(&db).await; db.add_constraint( "People".to_string(), "Age".to_string(), check_constraint("Age", Type::Int, "Age >= 0"), None, ) .await .unwrap(); let bad = db .insert( "People".to_string(), None, vec![ Value::Text("Eve".to_string()), Value::Number("-1".to_string()), Value::Bool(true), ], None, ) .await; assert!(bad.is_err(), "an insert violating the new CHECK is refused"); } #[tokio::test] async fn add_constraint_not_null_on_pk_column_is_refused() { let db = db(); people_table(&db).await; let result = db .add_constraint("People".to_string(), "id".to_string(), Constraint::NotNull, None) .await; assert!(result.is_err(), "a PK column is already NOT NULL (ADR-0029 §9)"); } #[tokio::test] async fn add_constraint_unique_on_single_column_pk_is_refused() { let db = db(); people_table(&db).await; let result = db .add_constraint("People".to_string(), "id".to_string(), Constraint::Unique, None) .await; assert!( result.is_err(), "a single-column PK is already UNIQUE (ADR-0029 §9)", ); } #[tokio::test] async fn add_constraint_unique_on_compound_pk_member_is_allowed() { // A compound PK does not make its members individually // unique, so `unique` on one of them is meaningful. let db = db(); db.create_table( "T".to_string(), vec![col("a", Type::Int), col("b", Type::Int)], vec!["a".to_string(), "b".to_string()], None, ) .await .unwrap(); let desc = db .add_constraint("T".to_string(), "a".to_string(), Constraint::Unique, None) .await .expect("UNIQUE on a compound-PK member is allowed"); assert!(desc.columns.iter().find(|c| c.name == "a").unwrap().unique); } #[tokio::test] async fn add_constraint_default_on_serial_column_is_refused() { let db = db(); people_table(&db).await; let result = db .add_constraint( "People".to_string(), "id".to_string(), Constraint::Default(Value::Number("5".to_string())), None, ) .await; assert!( result.is_err(), "a serial column auto-fills its own values (ADR-0029 §6)", ); } #[tokio::test] async fn add_constraint_to_missing_column_errors() { let db = db(); people_table(&db).await; let result = db .add_constraint("People".to_string(), "ghost".to_string(), Constraint::Unique, None) .await; assert!(result.is_err(), "no such column"); } #[tokio::test] async fn drop_constraint_removes_not_null() { let db = db(); people_table(&db).await; db.add_constraint( "People".to_string(), "Name".to_string(), Constraint::NotNull, None, ) .await .unwrap(); let desc = db .drop_constraint( "People".to_string(), "Name".to_string(), ConstraintKind::NotNull, None, ) .await .expect("the NOT NULL is dropped"); assert!( !desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, "the column is nullable again", ); } #[tokio::test] async fn drop_constraint_check_clears_the_constraint() { let db = db(); people_table(&db).await; db.add_constraint( "People".to_string(), "Age".to_string(), check_constraint("Age", Type::Int, "Age >= 0"), None, ) .await .unwrap(); let desc = db .drop_constraint( "People".to_string(), "Age".to_string(), ConstraintKind::Check, None, ) .await .expect("the CHECK is dropped"); assert!( desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_none(), "the CHECK is gone from the structure view", ); // With the CHECK gone, a previously-forbidden value inserts. db.insert( "People".to_string(), None, vec![ Value::Text("Eve".to_string()), Value::Number("-1".to_string()), Value::Bool(true), ], None, ) .await .expect("the CHECK no longer applies"); } #[tokio::test] async fn drop_constraint_not_null_on_pk_is_refused() { let db = db(); people_table(&db).await; let result = db .drop_constraint( "People".to_string(), "id".to_string(), ConstraintKind::NotNull, None, ) .await; assert!( result.is_err(), "the PK still enforces NOT NULL — nothing to drop (ADR-0029 §9)", ); } #[tokio::test] async fn drop_constraint_absent_constraint_is_refused() { let db = db(); people_table(&db).await; // `Name` carries no UNIQUE let result = db .drop_constraint( "People".to_string(), "Name".to_string(), ConstraintKind::Unique, None, ) .await; assert!( result.is_err(), "dropping a constraint the column never had is a friendly refusal", ); } }