Files
rdbms-playground/src/db.rs
T
claude@clouddev1 62f09bebc5 db: fix self-referential cascade over-count + SQL-delete render test
A self-referential ON DELETE CASCADE FK (e.g. T.ParentId -> T.id) is
returned by read_relationships_inbound as a child whose table IS the
delete target. The before/after row-count diff then includes the
directly-deleted rows (already in rows_affected), so deleting a chain
root reported 3 cascaded rows when only 2 were removed via the
self-reference.

Fix in both do_delete (DSL) and do_sql_delete (SQL): when the child
table equals the target, subtract rows_affected from the diff and
guard on the corrected count (a leaf delete no longer reports a
phantom 0-row self-cascade); the target's CSV is already queued, so a
self-ref child is not re-added to rewritten_tables. Pre-existing in
do_delete; surfaced by the 3f DA pass, fixed in both paths to keep
DSL/SQL parity. Behaviour: report only the rows removed via the
self-reference (user-confirmed).

Also adds an app-level render test for the SQL DELETE path
(handle_dsl_delete_success via CommandOutcome::Delete) — the shared
renderer's ok-summary + per-relationship cascade line were exercised
only through the DSL path before.

Test-first: self_referential_cascade_counts_only_cascaded_rows added
for both paths (asserted 2, failed at 3 before the fix). 1545 pass /
0 fail / 1 ignored. Clippy clean.
2026-05-22 19:17:43 +00:00

11180 lines
380 KiB
Rust

//! 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<Request>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableDescription {
pub name: String,
pub columns: Vec<ColumnDescription>,
/// Relationships where *this* table is the child (holds the
/// FK column referencing another table).
pub outbound_relationships: Vec<RelationshipEnd>,
/// Relationships where *this* table is the parent (some
/// other table's column references one of ours).
pub inbound_relationships: Vec<RelationshipEnd>,
/// User-created indexes on this table (ADR-0025).
pub indexes: Vec<IndexInfo>,
}
/// 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<String>,
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<RelationshipEnd>, Vec<RelationshipEnd>), 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<Type>,
/// 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<String>,
/// The column's `CHECK` constraint in compiled-SQL form,
/// or `None` (ADR-0029).
pub check: Option<String>,
}
#[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<String>,
pub column_types: Vec<Option<Type>>,
pub rows: Vec<Vec<Option<String>>>,
}
/// 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<ExplainRow>,
}
/// 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<String>,
}
/// 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<String>,
}
/// 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<ClientSideNote>,
}
/// 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<AutoFillKind>,
}
/// 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<CascadeEffect>,
}
/// 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<ColumnSpec>,
primary_key: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropTable {
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<(), DbError>>,
},
AddColumn {
table: String,
column: ColumnSpec,
source: Option<String>,
reply: oneshot::Sender<Result<AddColumnResult, DbError>>,
},
DropColumn {
table: String,
column: String,
cascade: bool,
source: Option<String>,
reply: oneshot::Sender<Result<DropColumnResult, DbError>>,
},
RenameColumn {
table: String,
old: String,
new: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
ChangeColumnType {
table: String,
column: String,
ty: Type,
mode: ChangeColumnMode,
source: Option<String>,
reply: oneshot::Sender<Result<ChangeColumnTypeResult, DbError>>,
},
ListTables {
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
DescribeTable {
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
AddRelationship {
name: Option<String>,
parent_table: String,
parent_column: String,
child_table: String,
child_column: String,
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropRelationship {
selector: RelationshipSelector,
source: Option<String>,
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
},
/// Create an index on a table (ADR-0025).
AddIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Drop an index by name or by table + column set (ADR-0025).
DropIndex {
selector: IndexSelector,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// 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<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Remove a column-level constraint from an existing column
/// (ADR-0029 §2.2).
DropConstraint {
table: String,
column: String,
kind: ConstraintKind,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
Insert {
table: String,
columns: Option<Vec<String>>,
values: Vec<Value>,
source: Option<String>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
Update {
table: String,
assignments: Vec<(String, Value)>,
filter: RowFilter,
source: Option<String>,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
Delete {
table: String,
filter: RowFilter,
source: Option<String>,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
QueryData {
table: String,
filter: Option<Expr>,
limit: Option<u64>,
source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// 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<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// Run a validated SQL `INSERT` typed in advanced mode
/// (ADR-0033 §1, sub-phase 3b). The grammar walker has
/// validated `sql` is in the supported subset; the worker
/// executes it as text, re-persists the target table's CSV
/// (ADR-0030 §11), and appends the literal line to
/// `history.log`. `target_table` comes from the parse so the
/// worker re-persists the right CSV without re-parsing.
RunSqlInsert {
sql: String,
source: Option<String>,
target_table: String,
listed_columns: Vec<String>,
row_source: String,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
/// worker executes `sql` as text, re-persists `target_table`'s
/// CSV (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlUpdate {
sql: String,
source: Option<String>,
target_table: String,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
/// worker executes `sql` as text, detects FK cascade by
/// row-count diffing the inbound children (Amendment 2),
/// re-persists `target_table`'s CSV plus every cascade-affected
/// child (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlDelete {
sql: String,
source: Option<String>,
target_table: String,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
/// 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<Result<QueryPlan, DbError>>,
},
/// 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<String>,
reply: oneshot::Sender<Result<(), DbError>>,
},
/// 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<RelationshipsReply>,
},
/// 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<Result<DataResult, DbError>>,
},
/// 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<Result<Vec<String>, 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<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
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/<table>.csv` files, and `history.log` *before*
/// the SQLite tx commits.
pub fn open_with_persistence<P: AsRef<Path>>(
path: P,
persistence: Persistence,
) -> Result<Self, DbError> {
Self::open_inner(path, Some(persistence))
}
fn open_inner<P: AsRef<Path>>(
path: P,
persistence: Option<Persistence>,
) -> Result<Self, DbError> {
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>(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<ColumnSpec>,
primary_key: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
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<String>) -> 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<String>,
) -> Result<AddColumnResult, DbError> {
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<String>,
) -> Result<DropColumnResult, DbError> {
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<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<ChangeColumnTypeResult, DbError> {
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<Vec<String>, 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<String>,
) -> Result<TableDescription, DbError> {
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<String>,
parent_table: String,
parent_column: String,
child_table: String,
child_column: String,
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
source: Option<String>,
) -> Result<TableDescription, DbError> {
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<String>,
) -> Result<Option<TableDescription>, 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<Vec<String>>,
values: Vec<Value>,
source: Option<String>,
) -> Result<InsertResult, DbError> {
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<String>,
) -> Result<UpdateResult, DbError> {
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<String>,
) -> Result<DeleteResult, DbError> {
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<String>,
) -> 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<Expr>,
limit: Option<u64>,
source: Option<String>,
) -> Result<DataResult, DbError> {
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<String>,
) -> Result<DataResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSelect { sql, source, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `INSERT` and return the affected-row
/// count plus the inserted rows (ADR-0033 §1, sub-phase 3b).
/// `sql` is the grammar-validated statement text; `source` is
/// the literal submitted line for `history.log`; `target_table`
/// is the parsed target whose CSV is re-persisted.
pub async fn run_sql_insert(
&self,
sql: String,
source: Option<String>,
target_table: String,
listed_columns: Vec<String>,
row_source: String,
) -> Result<InsertResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlInsert {
sql,
source,
target_table,
listed_columns,
row_source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `UPDATE` and return the affected-row
/// count (ADR-0033 §2, sub-phase 3e). `sql` is the
/// grammar-validated statement text; `source` is the literal
/// submitted line for `history.log`; `target_table` is the
/// parsed target whose CSV is re-persisted.
pub async fn run_sql_update(
&self,
sql: String,
source: Option<String>,
target_table: String,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlUpdate {
sql,
source,
target_table,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `DELETE` and return the affected-row
/// count plus any cascade effects (ADR-0033 §1/§7, sub-phase
/// 3f). `sql` is the grammar-validated statement text; `source`
/// is the literal submitted line for `history.log`;
/// `target_table` is the parsed target whose CSV (and whose
/// cascade-affected children's CSVs) are re-persisted.
pub async fn run_sql_delete(
&self,
sql: String,
source: Option<String>,
target_table: String,
) -> Result<DeleteResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlDelete {
sql,
source,
target_table,
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<QueryPlan, DbError> {
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<DataResult, DbError> {
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<Vec<String>, 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<Persistence>,
mut rx: mpsc::Receiver<Request>,
) {
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::RunSqlInsert {
sql,
source,
target_table,
listed_columns,
row_source,
reply,
} => {
let _ = reply.send(do_sql_insert(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
&listed_columns,
&row_source,
));
}
Request::RunSqlUpdate {
sql,
source,
target_table,
reply,
} => {
let _ = reply.send(do_sql_update(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
));
}
Request::RunSqlDelete {
sql,
source,
target_table,
reply,
} => {
let _ = reply.send(do_sql_delete(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
));
}
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<Vec<String>, 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 <table> WHERE <column> = <value> LIMIT <n>`.
/// 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<DataResult, DbError> {
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<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
schema.columns.iter().map(|c| c.user_type).collect();
let cols_csv = column_names
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.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<rusqlite::types::Value> =
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<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
let formatted: Vec<Option<String>> = 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<String>,
/// Tables that were dropped — their CSVs should be removed.
deleted_tables: Vec<String>,
}
/// 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<SchemaSnapshot, DbError> {
let table_names = do_list_tables(conn)?;
let mut tables: Vec<TableSchema> = Vec::with_capacity(table_names.len());
for name in &table_names {
let read = read_schema(conn, name)?;
let columns: Vec<ColumnSchema> = 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<IndexSchema> = 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<Vec<RelationshipSchema>, 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<String, DbError> {
let value: Option<String> = 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<Option<TableSnapshot>, DbError> {
if !user_table_exists(conn, table)? {
return Ok(None);
}
let read = read_schema(conn, table)?;
let columns: Vec<ColumnSchema> = 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<String> = 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<CellValue>> = 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<CellValue> = 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<bool, DbError> {
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<CellValue, DbError> {
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 <literal>` 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<String, DbError> {
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<Option<String>, 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<String, DbError> {
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<rusqlite::types::Value> = Vec::new();
let sql = compile_expr(expr, schema, &mut params);
inline_params_for_display(&sql, &params)
}
/// 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<TableDescription, DbError> {
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<Option<String>> = columns
.iter()
.map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
.collect();
let mut column_clauses: Vec<String> = 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<String> = 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<AddColumnResult, DbError> {
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<AddColumnResult, DbError> {
// 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<AddColumnResult, DbError> {
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<RV> = 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<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
let new_columns: Vec<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.join(", ");
let placeholders = (1..=new_columns.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.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<RV> = 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<AddColumnResult, DbError> {
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<TableDescription, DbError> {
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<String> = 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<TableDescription, DbError> {
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>, 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<Vec<DryRunRow>, DbError> {
use rusqlite::types::Value as RV;
let pk_columns = &schema.primary_key;
let mut select_idents: Vec<String> =
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<RV> = 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<String> {
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<Alignment> {
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<String> {
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<Vec<String>>,
) -> 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<Option<String>, 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<String>> = 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<Option<String>, 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<String, Vec<DryRunRow>> = BTreeMap::new();
for (pk, target) in rows {
groups
.entry(render_value(&target))
.or_default()
.push((pk, target));
}
let collisions: Vec<(String, Vec<DryRunRow>)> = 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<String>> = 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::<Vec<_>>()
.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<Option<String>, 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<String>> = 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<Vec<rusqlite::types::Value>, DbError> {
use std::collections::HashSet;
let mut taken: HashSet<String> = existing.iter().cloned().collect();
let mut out: Vec<rusqlite::types::Value> = Vec::with_capacity(count);
for _ in 0..count {
let mut generated: Option<String> = 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<DropColumnResult, DbError> {
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<IndexInfo> = 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::<Vec<_>>()
.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<TableDescription, DbError> {
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<ChangeColumnTypeResult, DbError> {
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<M>(
conn: &Connection,
table: &str,
column: &str,
src_ty: Type,
target_ty: Type,
mode: ChangeColumnMode,
old_schema: &ReadSchema,
new_schema: &ReadSchema,
metadata_updates: M,
) -> Result<ClientSideNote, DbError>
where
M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
{
use rusqlite::types::Value as RV;
use type_change::CellOutcome;
let pk_columns: Vec<String> = 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<String> = 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<usize> = 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::<Vec<_>>()
.join(", ");
let order_by = pk_columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let select_sql = format!(
"SELECT {select_cols} FROM {tbl} ORDER BY {order_by};",
tbl = quote_ident(table),
);
let mut rows: Vec<Vec<RV>> = 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<RV> = 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<Outcome> = Vec::with_capacity(rows.len());
for row in &rows {
let pk_values: Vec<RV> = 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<RV> = 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::<Vec<_>>()
.join(", ");
let placeholders = (0..all_columns.len())
.map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>()
.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<RV> = 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<String> = 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<String>> = 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<String>> = 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<String> {
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<String, Vec<usize>> = 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<usize>)> = 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<String>> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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<rusqlite::types::Value>,
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<String> {
pk_columns
.iter()
.map(|c| format!("{c} (PK)"))
.collect()
}
fn pk_header_alignments(pk_columns: &[String], schema: &ReadSchema) -> Vec<Alignment> {
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<String> {
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<String> = 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(_) => "<blob>".to_string(),
}
}
fn more_row(width: usize, more: usize) -> Vec<String> {
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<Vec<String>, 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<ReadColumn>,
primary_key: Vec<String>,
foreign_keys: Vec<ReadForeignKey>,
}
#[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<String>,
/// 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<String>,
user_type: Option<Type>,
}
#[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<ReadSchema, DbError> {
// 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<String> = row.get(4)?;
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().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<String> = 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<Vec<IndexInfo>, 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<Vec<String>, 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::<ReferentialAction>()
.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<std::collections::HashSet<String>, DbError> {
let mut out: std::collections::HashSet<String> = 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::<Result<Vec<_>, _>>()
.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<String> = info_stmt
.query_map([&idx_name], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?
.collect::<Result<Vec<_>, _>>()
.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<String> = 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<String> = 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<C, M>(
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::<Vec<_>>()
.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<F>(
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<String> = 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::<Vec<_>>()
.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(&copy_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<TableDescription, DbError> {
// 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 <Parent>.<col>
// to <Child>.<col>` 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 <Parent>.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<Option<TableDescription>, 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 <Parent> 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
/// `<table>_<col…>_idx` when not supplied.
fn do_add_index(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: Option<&str>,
table: &str,
columns: &[String],
) -> Result<TableDescription, DbError> {
// 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::<Vec<_>>()
.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<TableDescription, DbError> {
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<IndexInfo> = 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::<Vec<_>>()
.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<TableDescription, DbError> {
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<TableDescription, DbError> {
// 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<ColumnDescription> = 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<Bound, DbError> {
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<rusqlite::types::Value>,
) -> 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<rusqlite::types::Value>,
) -> String {
let parts: Vec<String> = 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<rusqlite::types::Value>,
) -> 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<String> = 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<Type>,
params: &mut Vec<rusqlite::types::Value>,
) -> 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<Type> {
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<Type>) -> 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::<i64>().map_or_else(
|_| {
s.parse::<f64>()
.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<usize, DbError> {
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<DataResult, DbError> {
let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
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::<Vec<_>>()
.join(", ");
let placeholders = (1..=rowids.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.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<rusqlite::types::Value> = 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<rusqlite::types::Value> =
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<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
let formatted: Vec<Option<String>> = 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<i64, DbError> {
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<InsertResult, DbError> {
let schema = read_schema(conn, table)?;
// Resolve which columns the user is providing values for.
let user_cols: Vec<String> = 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<String> =
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::<Vec<_>>()
.join(", ");
let placeholders = (1..=bindings.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
ident = quote_ident(table),
);
debug!(sql = %sql, "insert");
let params: Vec<rusqlite::types::Value> =
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, &params)?;
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<rusqlite::types::Value>), DbError> {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let mut set_clauses: Vec<String> = 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<UpdateResult, DbError> {
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<rusqlite::types::Value> = 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, &params)?;
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<Vec<i64>, 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<rusqlite::types::Value>) {
let mut params: Vec<rusqlite::types::Value> = 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<DeleteResult, DbError> {
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, &params)?;
// 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<CascadeEffect> = Vec::new();
let mut rewritten_tables: Vec<String> = vec![table.to_string()];
for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) {
let after_count = count_rows(conn, &rel.other_table)?;
let mut rows_changed = before_count - after_count;
// A self-referential FK (child == target): the before/after
// diff also covers the directly-deleted rows, which are
// already reported in `rows_affected` and are not cascade
// effects. Subtract them so the summary reports only the rows
// removed *via* the self-reference. Shared with `do_sql_delete`.
let self_referential = rel.other_table == table;
if self_referential {
rows_changed -= rows_affected as i64;
}
if rows_changed > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed,
action: rel.on_delete,
});
// The target's CSV is already queued; only add a distinct
// child table (a self-ref child is the target itself).
if !self_referential {
rewritten_tables.push(rel.other_table.clone());
}
}
}
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
})
}
/// 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<u64>,
) -> Result<DataResult, DbError> {
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<DataResult, DbError> {
let data = do_run_select(conn, sql)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
}
/// Currently-stored non-NULL values of one column, for shortid
/// collision-avoidance (passed to `generate_shortid_batch`).
fn existing_shortids(
conn: &Connection,
table: &str,
column: &str,
) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(&format!(
"SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;",
col = quote_ident(column),
tbl = quote_ident(table),
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6,
/// sub-phase 3d).
///
/// Returns the SQL the worker should execute plus its bound
/// params. When the user's `(column_list)` omits one or more
/// `shortid` columns, this materialises the row source (Option B:
/// run it as a query), synthesises a fresh distinct id per row via
/// `generate_shortid_batch`, and reconstructs a parameterised
/// multi-row `INSERT` over the listed columns plus the omitted
/// shortid columns. Otherwise it returns the original `sql`
/// verbatim with no params (the 3b path):
///
/// - no explicit column list → the row source supplies every
/// column positionally (a listed shortid is the user's value);
/// - no omitted shortid column → nothing to fill;
/// - the row source yields zero rows → nothing to fill (the
/// verbatim INSERT inserts nothing without a NOT-NULL violation).
///
/// `serial` columns are not handled here — an omitted `serial`
/// primary key is filled by the engine's rowid (ADR-0033 §6).
fn plan_shortid_autofill(
conn: &Connection,
target_table: &str,
sql: &str,
listed_columns: &[String],
row_source: &str,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
if listed_columns.is_empty() {
return Ok((sql.to_string(), Vec::new()));
}
let schema = read_schema(conn, target_table)?;
// Identifiers are case-preserving but matched case-insensitively
// (ADR-0009): a shortid column counts as omitted unless the user
// listed a name equal to it ignoring ASCII case.
let listed_ci: Vec<String> =
listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect();
let omitted_shortids: Vec<String> = schema
.columns
.iter()
.filter(|c| c.user_type == Some(Type::ShortId))
.filter(|c| !listed_ci.contains(&c.name.to_ascii_lowercase()))
.map(|c| c.name.clone())
.collect();
if omitted_shortids.is_empty() {
return Ok((sql.to_string(), Vec::new()));
}
// Materialise the row source (VALUES / SELECT / WITH … SELECT)
// as concrete rows for the listed columns.
let listed_count = listed_columns.len();
let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?;
// Arity guard: if the row source's column count disagrees with
// the user's column list, do NOT auto-fill — reading
// `listed_count` cells would silently drop extra columns (or
// error opaquely on too few). Defer to the verbatim statement
// so the engine reports the mismatch as it does on the
// non-auto-fill path (a friendly pre-flight lands in 3i).
if stmt.column_count() != listed_count {
return Ok((sql.to_string(), Vec::new()));
}
let mut rows: Vec<Vec<rusqlite::types::Value>> = Vec::new();
{
let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?;
while let Some(r) = q.next().map_err(DbError::from_rusqlite)? {
let mut cells = Vec::with_capacity(listed_count);
for i in 0..listed_count {
cells.push(
r.get::<_, rusqlite::types::Value>(i)
.map_err(DbError::from_rusqlite)?,
);
}
rows.push(cells);
}
}
let n = rows.len();
if n == 0 {
// Nothing to insert — the verbatim statement inserts zero
// rows without touching the omitted shortid column.
return Ok((sql.to_string(), Vec::new()));
}
// A fresh, distinct shortid per row for each omitted column,
// avoiding collision with values already stored in that column.
let mut id_batches: Vec<Vec<rusqlite::types::Value>> =
Vec::with_capacity(omitted_shortids.len());
for col in &omitted_shortids {
let existing = existing_shortids(conn, target_table, col)?;
id_batches.push(generate_shortid_batch(n, &existing)?);
}
// Reconstruct: listed columns followed by the omitted shortid
// columns; one parameterised tuple per materialised row.
let all_cols: Vec<&String> =
listed_columns.iter().chain(omitted_shortids.iter()).collect();
let cols_csv = all_cols
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let per_tuple = all_cols.len();
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(n * per_tuple);
let mut tuples: Vec<String> = Vec::with_capacity(n);
let mut ph = 1;
for (row_idx, row) in rows.into_iter().enumerate() {
for cell in row {
params.push(cell);
}
for batch in &id_batches {
params.push(batch[row_idx].clone());
}
let placeholders = (0..per_tuple)
.map(|_| {
let s = format!("?{ph}");
ph += 1;
s
})
.collect::<Vec<_>>()
.join(", ");
tuples.push(format!("({placeholders})"));
}
let exec_sql = format!(
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals};",
tbl = quote_ident(target_table),
vals = tuples.join(", "),
);
Ok((exec_sql, params))
}
/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1,
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
/// run the validated SQL inside a transaction, re-persist the
/// target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()` (so a
/// persistence failure rolls the insert back), then commit.
///
/// Grammar-as-text (ADR-0030 §4): normally the values are literals
/// in `sql` and no parameters are bound. The sub-phase 3d shortid
/// auto-fill path is the exception — it reconstructs a
/// parameterised `INSERT` (see `plan_shortid_autofill`); either
/// way `history.log` records the original `source`, never the
/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL
/// engine errors surface enriched via `execute_with_fk_enrichment`
/// + the friendly-error layer.
///
/// Auto-show is best-effort: the inserted rows are the last
/// `rows_affected` rowids ending at `last_insert_rowid()`. For the
/// common case (sequential / engine-assigned rowids) this is
/// exactly the inserted rows; an INSERT that sets explicit
/// non-contiguous rowid/INTEGER-PK values may surface a partial
/// view. `RETURNING` (sub-phase 3g) is the precise tool.
fn do_sql_insert(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
listed_columns: &[String],
row_source: &str,
) -> Result<InsertResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_insert");
// Sub-phase 3d: when the user's column list omits one or more
// `shortid` columns, the worker materialises the row source,
// synthesises fresh distinct ids, and reinserts the augmented
// rows. Returns the executable SQL + bound params; an empty
// params vec with the original `sql` means "no auto-fill —
// execute verbatim" (the 3b path).
let (exec_sql, params) =
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source)?;
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected =
execute_with_fk_enrichment(conn, target_table, &exec_sql, &params)?;
let last = conn.last_insert_rowid();
let rowids: Vec<i64> = if rows_affected == 0 {
Vec::new()
} else {
let n = rows_affected as i64;
((last - n + 1)..=last).collect()
};
let data = query_rows_by_rowid(conn, target_table, &rowids)?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(InsertResult {
rows_affected,
data,
})
}
/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2,
/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence
/// discipline: run the validated SQL inside a transaction,
/// re-persist the target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()`, then commit.
///
/// Grammar-as-text (ADR-0030 §4): the assignment and predicate
/// values are literals in `sql`, so no parameters are bound. A SQL
/// `UPDATE` without `WHERE` runs across all rows as written
/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero
/// rows is a success (`rows_affected == 0`); the persistence
/// write-through still runs (re-persisting the unchanged CSV is a
/// no-op-equivalent and keeps the path uniform).
///
/// Auto-show: 3e returns an empty [`DataResult`] — the affected
/// rows can't be shown precisely without `RETURNING` (sub-phase
/// 3g, which is the precise tool). The summary surfaces the
/// affected-row count; the renderer skips the (column-less) table.
fn do_sql_update(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
) -> Result<UpdateResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_update");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(UpdateResult {
rows_affected,
data: DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
})
}
/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
/// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing
/// only in that it executes the verbatim grammar-validated `sql`
/// rather than building the statement from a typed filter.
///
/// Cascade detection (ADR-0033 Amendment 2): the worker snapshots
/// each inbound child table's row count *before* the DELETE, runs
/// the statement inside a transaction (the engine applies any
/// `ON DELETE CASCADE`), counts the children again *after*, and
/// reports the positive difference as a [`CascadeEffect`]. This is
/// the identical mechanism the DSL path uses, so the SQL and DSL
/// DELETE produce the same per-relationship summary on the same
/// schema/data — and since both return a [`DeleteResult`] routed
/// through `CommandOutcome::Delete`, the render-layer formatter is
/// shared with no duplication. `ON DELETE SET NULL` leaves row
/// counts unchanged and so is not reported on either path (a
/// deferred enhancement for both).
///
/// Because the diff observes the result of executing the whole
/// statement, the WHERE clause is never inspected — a WHERE that
/// itself contains a subquery (the R2 invariant) is correct by
/// construction and carries no extra per-child query cost.
///
/// Persistence discipline matches `do_delete` / `do_sql_update`:
/// re-persist the target's CSV *and every cascade-affected child's*
/// CSV via `finalize_persistence` (which also appends `source` to
/// `history.log`) *before* `tx.commit()`, so a persistence failure
/// rolls the delete back. A DELETE matching zero rows is a success
/// (`rows_affected == 0`, empty cascade); the target's CSV is still
/// re-persisted, keeping the path uniform.
fn do_sql_delete(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
) -> Result<DeleteResult, DbError> {
debug!(sql = %sql, table = %target_table, "sql_delete");
// Snapshot child-table row counts before the delete so cascade
// effects can be detected by diffing afterwards (Amendment 2;
// identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET
// NULL do not change row counts and so are not detected here.
let inbound = read_relationships_inbound(conn, target_table)?;
let mut before_counts: Vec<i64> = Vec::with_capacity(inbound.len());
for r in &inbound {
before_counts.push(count_rows(conn, &r.other_table)?);
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
// Compare child-table counts after the delete; positive diffs
// are cascade effects. Collect the cascaded tables so the
// persistence phase rewrites their CSVs too.
let mut cascade: Vec<CascadeEffect> = Vec::new();
let mut rewritten_tables: Vec<String> = vec![target_table.to_string()];
for (rel, before_count) in inbound.iter().zip(before_counts.iter()) {
let after_count = count_rows(conn, &rel.other_table)?;
let mut rows_changed = before_count - after_count;
// A self-referential FK (child == target): the before/after
// diff also covers the directly-deleted rows, which are
// already reported in `rows_affected` and are not cascade
// effects. Subtract them so the summary reports only the rows
// removed *via* the self-reference.
let self_referential = rel.other_table == target_table;
if self_referential {
rows_changed -= rows_affected as i64;
}
if rows_changed > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed,
action: rel.on_delete,
});
// The target's CSV is already queued; only add a distinct
// child table (a self-ref child is the target itself).
if !self_referential {
rewritten_tables.push(rel.other_table.clone());
}
}
}
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
})
}
/// Execute a grammar-validated SQL `SELECT` and collect its
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
/// Amendment 1).
///
/// Per-column playground types are recovered from the engine's
/// column-origin metadata (`column_table_name` /
/// `column_origin_name`, surfaced by rusqlite's
/// `columns_with_metadata`). The Amendment-1 empirical probe
/// confirmed the metadata follows through non-recursive CTEs,
/// scalar subqueries, derived tables, set ops, and JOINs; only
/// computed projections and recursive-CTE result columns return
/// `None`. The renderer (ADR-0016) handles typed columns
/// (bool → true/false, etc.) and falls back to neutral
/// alignment for `None`.
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
debug!(sql = %sql, "run_select");
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
let column_names: Vec<String> = stmt
.column_names()
.into_iter()
.map(String::from)
.collect();
let col_count = column_names.len();
let column_types = resolve_select_column_types(conn, &stmt);
let rows_iter = stmt
.query_map([], |row| {
let mut cells: Vec<rusqlite::types::Value> = 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<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
rows.push(
cells
.into_iter()
.enumerate()
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
.collect(),
);
}
Ok(DataResult {
table_name: String::new(),
columns: column_names,
column_types,
rows,
})
}
/// Resolve playground types for each result column of a
/// prepared SELECT statement (ADR-0032 §12 + Amendment 1).
///
/// For each result column, query the engine's column-origin
/// metadata. If both `table_name` and `origin_name` are
/// populated (the result column traces back to a base-table
/// column), look up the playground type in
/// `__rdbms_playground_columns`. Otherwise the slot stays
/// `None` — Amendment 1 documents that recursive-CTE result
/// columns and computed projections are the only structural
/// classes that don't follow through.
fn resolve_select_column_types(
conn: &Connection,
stmt: &rusqlite::Statement,
) -> Vec<Option<Type>> {
let metas = stmt.columns_with_metadata();
if metas.is_empty() {
return Vec::new();
}
// Prepare the lookup once; reuse across columns.
let mut lookup = match conn.prepare(&format!(
"SELECT user_type FROM {META_TABLE} \
WHERE table_name = ?1 COLLATE NOCASE \
AND column_name = ?2 COLLATE NOCASE"
)) {
Ok(s) => s,
Err(_) => return vec![None; metas.len()],
};
metas
.iter()
.map(|m| {
let table = m.table_name()?;
let origin = m.origin_name()?;
lookup
.query_row(rusqlite::params![table, origin], |row| {
row.get::<_, String>(0)
})
.ok()
.and_then(|kw| kw.parse::<Type>().ok())
})
.collect()
}
/// Build the parameterised `SELECT … FROM …` statement for a
/// `show data` query (ADR-0026 §5–§6). Separated from
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
/// PLAN` against the *exact* same statement (ADR-0028 §2).
///
/// A `limit` implies a stable primary-key `ORDER BY` so
/// `limit n` is "first n by primary key" rather than an
/// arbitrary subset.
fn build_query_data_sql(
schema: &ReadSchema,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> (String, Vec<rusqlite::types::Value>) {
let cols_csv = schema
.columns
.iter()
.map(|c| quote_ident(&c.name))
.collect::<Vec<_>>()
.join(", ");
let mut params: Vec<rusqlite::types::Value> = 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::<Vec<_>>()
.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<u64>,
) -> Result<DataResult, DbError> {
let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
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<rusqlite::types::Value> = 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<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
let formatted: Vec<Option<String>> = 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<Type>) -> Option<String> {
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!("<blob {} bytes>", 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<QueryPlan, DbError> {
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, &params)?;
let display_sql = inline_params_for_display(&exec_sql, &params);
debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
Ok(QueryPlan { display_sql, rows })
}
/// Prepare `EXPLAIN QUERY PLAN <sql>` 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<Vec<ExplainRow>, 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::<usize>()
.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<Vec<RelationshipEnd>, 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::<ReferentialAction>()
.unwrap_or(ReferentialAction::NoAction),
on_update: on_update
.parse::<ReferentialAction>()
.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<Vec<RelationshipEnd>, 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::<ReferentialAction>()
.unwrap_or(ReferentialAction::NoAction),
on_update: on_update
.parse::<ReferentialAction>()
.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/<table>.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::<Vec<_>>()
.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<ReadColumn> = 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<ReadForeignKey> = 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::<Vec<_>>()
.join(", ");
let placeholders = (1..=table.columns.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.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<rusqlite::types::Value> = 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<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().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<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().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<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().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<i64> = data
.rows
.iter()
.filter_map(|r| r[code_idx].as_ref().and_then(|s| s.parse::<i64>().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<Expr>, Option<u64>) {
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<String> {
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<Value>,
) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
not_null,
unique,
default,
check: None,
}
}
/// Create `T(id serial pk, <extra>)`.
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<ColumnSpec>, Vec<String>) {
match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
Command::CreateTable {
name,
columns,
primary_key,
} => (name, columns, primary_key),
other => panic!("expected CreateTable, got {other:?}"),
}
}
/// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
let (_, columns, _) = parse_create(&format!(
"create table __probe with pk {name}({}) check ({check_dsl})",
ty.keyword(),
));
columns.into_iter().next().expect("one column")
}
#[tokio::test]
async fn create_table_check_constraint_is_enforced() {
let db = db();
let (n, c, pk) = parse_create(
"create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
);
db.create_table(n, c, pk, None).await.unwrap();
let insert_grade = |g: &str| {
db.insert(
"Grades".to_string(),
Some(vec!["grade".to_string()]),
vec![Value::Text(g.to_string())],
None,
)
};
assert!(insert_grade("A").await.is_ok(), "a value the check allows");
assert!(
insert_grade("Z").await.is_err(),
"a value the check forbids is refused",
);
}
#[tokio::test]
async fn describe_surfaces_the_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk age(int) check (age >= 0)");
db.create_table(n, c, pk, None).await.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
let check = age.check.as_deref().expect("age carries a CHECK");
assert!(
check.contains(">="),
"the compiled check SQL is surfaced: {check}",
);
}
#[tokio::test]
async fn add_column_check_constraint_is_enforced() {
let db = db();
people_table(&db).await;
db.add_column(
"People".to_string(),
col_c_check("score", Type::Int, "score >= 0"),
None,
)
.await
.expect("a CHECK column adds via the rebuild path");
let desc = db.describe_table("People".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some());
// An update that violates the check is refused.
let bad = db
.update(
"People".to_string(),
vec![("score".to_string(), Value::Number("-1".to_string()))],
parse_filter("update People set score=-1 where id = 1"),
None,
)
.await;
assert!(bad.is_err(), "an update violating the CHECK is refused");
}
#[tokio::test]
async fn rebuild_preserves_a_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk code(text) check (code like 'X%')");
db.create_table(n, c, pk, None).await.unwrap();
db.add_column("T".to_string(), col("note", Type::Int), None)
.await
.unwrap();
// A type change on `note` rebuilds the table; `code`'s
// CHECK must survive the round-trip through schema_to_ddl.
db.change_column_type(
"T".to_string(),
"note".to_string(),
Type::Decimal,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
assert!(
desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(),
"code keeps its CHECK across the rebuild",
);
}
#[tokio::test]
async fn add_serial_column_with_a_check_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c_check("seq", Type::Serial, "seq > 0"),
None,
)
.await;
assert!(
result.is_err(),
"a CHECK on an auto-generated column is a create-table-only feature",
);
}
#[tokio::test]
async fn update_with_all_rows_affects_everything() {
let db = db();
customers_table(&db).await;
for name in ["Alice", "Bob", "Carol"] {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
None)
.await
.unwrap();
}
let result = db
.update(
"Customers".to_string(),
vec![("Name".to_string(), Value::Text("X".to_string()))],
RowFilter::AllRows,
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 3);
assert_eq!(result.data.rows.len(), 3);
}
#[tokio::test]
async fn delete_with_where_removes_matching_rows() {
let db = db();
customers_table(&db).await;
for name in ["Alice", "Bob"] {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
None)
.await
.unwrap();
}
let result = db
.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
assert!(result.cascade.is_empty(), "no children to cascade to");
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][1], Some("Bob".to_string()));
}
#[tokio::test]
async fn fk_violation_returns_engine_classified_constraint_error() {
// Pre-H1 (ADR-0019), this test asserted that the
// engine's `FOREIGN KEY constraint failed` text was
// enriched in-band with a list of every relevant FK on
// the offending table. That enrichment moved into the
// friendly-error layer's catalog wording (see
// `friendly::translate::tests::fk_with_*_op_renders_*`)
// and out of the raw `DbError::Sqlite { message }`
// payload. What stays in `message` is the engine's
// un-enriched text — useful for the translator's own
// classification, but no longer the user-facing
// surface.
//
// This test now just confirms the engine error reaches
// us classified as a constraint violation; user-facing
// wording is exercised in the friendly module.
let db = db();
customers_table(&db).await;
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial), col("CustId", Type::Int)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
let err = db
.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("999".to_string())],
None)
.await
.unwrap_err();
match err {
DbError::Sqlite { message, .. } => {
assert!(
message.to_ascii_lowercase().contains("foreign key"),
"expected engine-classified FK message, got: {message}"
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn cascade_delete_propagates_to_children() {
let db = db();
customers_table(&db).await;
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial), col("CustId", Type::Int)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
None)
.await
.unwrap();
db.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("1".to_string())],
None)
.await
.unwrap();
// Delete Alice — cascades to Orders.
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None)
.await
.unwrap();
let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap();
assert!(orders.rows.is_empty(), "child rows should be cascaded");
}
#[tokio::test]
async fn self_referential_cascade_counts_only_cascaded_rows() {
// A self-referential ON DELETE CASCADE FK (T.ParentId -> T.id):
// deleting the root of a chain cascades down within T. The
// directly-deleted root is reported in `rows_affected`, so the
// cascade summary must report only the *additional* rows
// removed by the self-reference — the raw before/after diff
// would double-count the direct delete. Parity fix shared with
// `do_sql_delete` (ADR-0033 Amendment 2 mechanism).
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Int), col("ParentId", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
Some("parent_of".to_string()),
"T".to_string(),
"id".to_string(),
"T".to_string(),
"ParentId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Chain: 1 (root) <- 2 <- 3.
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("1".to_string()), Value::Null],
None,
)
.await
.unwrap();
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("2".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("3".to_string()), Value::Number("2".to_string())],
None,
)
.await
.unwrap();
let result = db
.delete(
"T".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 1, "one row matched the filter directly");
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
assert_eq!(
result.cascade[0].rows_changed, 2,
"only the 2 cascaded rows, not the directly-deleted root too"
);
}
#[tokio::test]
async fn query_data_renders_bools_as_words() {
let db = db();
db.create_table(
"Flags".to_string(),
vec![col("id", Type::Serial), col("Active", Type::Bool)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(true)],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(false)],
None)
.await
.unwrap();
let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][1], Some("true".to_string()));
assert_eq!(data.rows[1][1], Some("false".to_string()));
}
#[tokio::test]
async fn query_data_renders_null_as_none() {
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("note", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"T".to_string(),
None,
vec![Value::Null],
None)
.await
.unwrap();
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][1], None);
}
// ---- ADR-0018 UNIQUE infrastructure ----
#[test]
fn read_schema_detects_single_column_unique() {
// Create a raw connection (no Database wrapper) so we
// can drop arbitrary DDL containing UNIQUE — the DSL
// doesn't yet expose UNIQUE as a user-controlled
// constraint (C3-track).
let conn = Connection::open_in_memory().unwrap();
configure_connection(&conn).unwrap();
conn.execute_batch(
"CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;",
)
.unwrap();
let schema = read_schema(&conn, "T").unwrap();
let id = schema.columns.iter().find(|c| c.name == "id").unwrap();
let code = schema.columns.iter().find(|c| c.name == "code").unwrap();
assert!(id.primary_key, "id should still be PK");
assert!(
!id.unique,
"PK columns are not separately marked unique \
(PK already implies it; double-marking would lead to \
redundant DDL)"
);
assert!(code.unique, "code should be detected as unique");
}
#[test]
fn read_schema_ignores_compound_unique_constraints() {
// Multi-column UNIQUE is out of scope for ADR-0018; the
// detection helper filters it out so schema_to_ddl does
// not accidentally emit a column-level UNIQUE for one
// half of a compound constraint.
let conn = Connection::open_in_memory().unwrap();
configure_connection(&conn).unwrap();
conn.execute_batch(
"CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT, UNIQUE(a, b)) STRICT;",
)
.unwrap();
let schema = read_schema(&conn, "T").unwrap();
for col in &schema.columns {
assert!(
!col.unique,
"column {} should not be marked unique under compound UNIQUE",
col.name
);
}
}
#[test]
fn schema_to_ddl_emits_unique_for_marked_column() {
// Construct a ReadSchema with a unique non-PK column and
// confirm the round-trip through schema_to_ddl produces
// valid DDL containing UNIQUE.
let schema = ReadSchema {
columns: vec![
ReadColumn {
name: "id".to_string(),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default_sql: None,
check: None,
user_type: Some(Type::Serial),
},
ReadColumn {
name: "code".to_string(),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(Type::Text),
},
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![],
};
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",
);
}
}