//! 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}; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, Operand, Predicate, RelationshipSelector, RowFilter, }; 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, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, }; use crate::project::{DATA_DIR, PROJECT_YAML}; /// 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, } /// 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, } /// 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>, }, DropTable { name: String, 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>, }, 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>, }, 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>, }, /// 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>>, }, } 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) } /// 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)) } fn open_inner>( path: P, persistence: Option, ) -> 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)?; let (tx, rx) = mpsc::channel::(REQUEST_CHANNEL_CAPACITY); thread::Builder::new() .name("rdbms-db-worker".to_string()) .spawn(move || worker_loop(conn, persistence, 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)? } 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)? } 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)? } 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)? } 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)? } /// 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)? } 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"; 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 {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, mut rx: mpsc::Receiver, ) { debug!("db worker started"); while let Some(req) = rx.blocking_recv() { handle_request(&conn, persistence.as_ref(), req); } debug!("db worker exiting"); } fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Request) { match req { Request::CreateTable { name, columns, primary_key, source, reply, } => { let _ = reply.send(do_create_table( conn, persistence, source.as_deref(), &name, &columns, &primary_key, )); } Request::DropTable { name, source, reply, } => { let _ = reply.send(do_drop_table(conn, persistence, source.as_deref(), &name)); } Request::AddColumn { table, column, source, reply, } => { let _ = reply.send(do_add_column( conn, persistence, source.as_deref(), &table, &column, )); } Request::DropColumn { table, column, cascade, source, reply, } => { let _ = reply.send(do_drop_column( conn, persistence, source.as_deref(), &table, &column, cascade, )); } Request::RenameColumn { table, old, new, source, reply, } => { let _ = reply.send(do_rename_column( conn, persistence, source.as_deref(), &table, &old, &new, )); } Request::ChangeColumnType { table, column, ty, mode, source, reply, } => { let _ = reply.send(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, } => { let _ = reply.send(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, } => { let _ = reply.send(do_drop_relationship( conn, persistence, source.as_deref(), &selector, )); } Request::AddIndex { name, table, columns, source, reply, } => { let _ = reply.send(do_add_index( conn, persistence, source.as_deref(), name.as_deref(), &table, &columns, )); } Request::DropIndex { selector, source, reply, } => { let _ = reply.send(do_drop_index( conn, persistence, source.as_deref(), &selector, )); } Request::AddConstraint { table, column, constraint, source, reply, } => { let _ = reply.send(do_add_constraint( conn, persistence, source.as_deref(), &table, &column, &constraint, )); } Request::DropConstraint { table, column, kind, source, reply, } => { let _ = reply.send(do_drop_constraint( conn, persistence, source.as_deref(), &table, &column, kind, )); } Request::Insert { table, columns, values, source, reply, } => { let _ = reply.send(do_insert( conn, persistence, source.as_deref(), &table, columns.as_deref(), &values, )); } Request::Update { table, assignments, filter, source, reply, } => { let _ = reply.send(do_update( conn, persistence, source.as_deref(), &table, &assignments, &filter, )); } Request::Delete { table, filter, source, reply, } => { let _ = reply.send(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::RebuildFromText { project_path, source, reply, } => { let _ = reply.send(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); } } } /// 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, }); } 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, }); } } 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) } 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"); } 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(), } } /// 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(()) } fn do_create_table( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, name: &str, columns: &[ColumnSpec], primary_key: &[String], ) -> 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(), )); } // Generate the column list. For a single-column PK we inline // `PRIMARY KEY` on the column itself, which is required for // SQLite STRICT tables to give an `INTEGER PRIMARY KEY` // column its rowid-alias semantics. For compound PKs (or // when the single PK is on a non-first column) we emit a // table-level constraint. let single_inline_pk = primary_key.len() == 1 && columns.len() == 1 && primary_key[0] == columns[0].name; // 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); let check_sqls: Vec> = columns .iter() .map(|c| 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 single_inline_pk { clause.push_str(" PRIMARY KEY"); } // ADR-0029 column constraints. A single-column PK is // already NOT NULL + UNIQUE; the grammar rejects // redundant declarations (ADR-0029 §9) so a 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 !single_inline_pk && !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(')'); } 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())?; } 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> { // 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)?; 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 { 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. if column.default.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). if column.check.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). if column.unique || column.check.is_some() || (column.not_null && column.default.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. if spec.not_null && spec.default.is_none() && 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 && spec.default.is_some() && 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, ))); } // 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: default_sql_literal(spec)?, check: None, user_type: Some(spec.ty), }); // The CHECK is compiled against the post-add schema, so it // may reference the new column itself. let check_sql = 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 { 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 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 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." ))); } 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 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, }); } 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) } /// 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 { 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 type of `{table}.{column}` while a relationship \ uses it as a foreign key; drop the relationship first." ))); } // (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, } #[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 single-column UNIQUE constraints (ADR-0018 §4). // 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"). For each, pragma_index_info // gives the constituent column(s); we only mark single- // column unique here. Compound UNIQUE is out of scope. let unique_columns = read_unique_columns(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)?); } Ok(ReadSchema { columns, primary_key, foreign_keys, }) } /// 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). fn read_unique_columns( conn: &Connection, table: &str, ) -> Result, DbError> { let mut out: std::collections::HashSet = std::collections::HashSet::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);") .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)?; if cols.len() == 1 { out.insert(cols.into_iter().next().expect("len 1")); } } Ok(out) } /// 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(", "))); } 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, ) } #[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 { // 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(), ))?; if actual != expected_child_type { return Err(DbError::Unsupported(format!( "type mismatch: `{child_table}.{child_column}` is `{actual}` but \ a foreign key referencing `{parent_table}.{parent_column}` \ (`{parent_user_type}`) requires `{expected_child_type}`. \ Either change the column type, or pick a different FK column." ))); } } // 4. Determine relationship name (auto-gen or supplied) and // check uniqueness against the metadata table. let resolved_name = name.map_or_else( // Auto-name follows the user-typed `from .// to .` direction so the name reads as the // grammar reads — see ADR-0013. || format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"), ToString::to_string, ); let collision: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"), [&resolved_name], |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; if collision > 0 { return Err(DbError::Unsupported(format!( "a relationship named `{resolved_name}` already exists. \ Pick a different name or drop the existing one first." ))); } // 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 on_delete_kw = on_delete.keyword(); let on_update_kw = on_update.keyword(); let column_user_type_kw = expected_child_type.keyword(); let resolved_name_for_meta = resolved_name.as_str(); 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)?; } tx.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);" ), [ resolved_name_for_meta, parent_table, parent_column, child_table, child_column, on_delete_kw, on_update_kw, ], ) .map_err(DbError::from_rusqlite)?; // 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)?)) } /// 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. fn do_add_index( conn: &Connection, persistence: Option<&Persistence>, source: Option<&str>, name: Option<&str>, table: &str, columns: &[String], ) -> Result { // 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. let existing = read_table_indexes(conn, table)?; if let Some(dup) = existing .iter() .find(|i| i.columns.as_slice() == columns) { 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 = name.map_or_else( || format!("{table}_{}_idx", columns.join("_")), ToString::to_string, ); // 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 ddl = format!( "CREATE INDEX {idx} ON {tbl} ({cols});", idx = quote_ident(&resolved), tbl = quote_ident(table), cols = cols_csv, ); debug!(ddl = %ddl, "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, }) } // --- 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 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 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 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 diff = before_count - after_count; if diff > 0 { cascade.push(CascadeEffect { relationship_name: rel.name.clone(), child_table: rel.other_table.clone(), rows_changed: diff, action: rel.on_delete, }); 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, }) } /// 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) } /// Execute a grammar-validated SQL `SELECT` and collect its /// rows into a [`DataResult`] (ADR-0030 §6). /// /// All result columns are reported with `column_types = None` — /// a SELECT result has no playground type unless we resolve /// each output column back to its source column, which is a /// Phase-2 (full `SELECT`) concern. The DSL data-table renderer /// (ADR-0016) renders typeless columns with neutral alignment. 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: Vec> = vec![None; col_count]; 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() .map(|v| format_cell(v, None)) .collect(), ); } Ok(DataResult { table_name: String::new(), columns: column_names, column_types, rows, }) } /// 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)?; // 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)?; } } // 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(", "); tx.execute_batch(&format!( "CREATE 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, } } /// 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(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } #[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`. fn parse_filter(dsl: &str) -> RowFilter { match crate::dsl::parser::parse_command(dsl).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`. fn parse_inner(dsl: &str) -> Command { crate::dsl::parser::parse_command(dsl).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, } } /// 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 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![], }; 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", ); } }