Files
rdbms-playground/src/db.rs
T
claude@clouddev1 0dc159fd7e Indexes: add index / drop index, persistence, display (ADR-0025)
Implement ADR-0025 — indexes as a DSL DDL feature.

- Grammar: `add index [as <name>] on <T> (<cols>)`, `drop index
  <name>` / `drop index on <T> (<cols>)`, plus a `--cascade`
  flag on `drop column`.
- db.rs: index operations over the engine's native index
  catalog (no metadata table). The rebuild-table primitive now
  captures and recreates indexes, so `change column` and the
  relationship operations no longer silently drop them.
- `drop column` refuses an indexed column unless `--cascade`,
  which drops the covering indexes and reports each.
- Persistence: additive `indexes:` list in `project.yaml`
  (version unchanged); round-trips through rebuild/export/import.
- Display: an `Indexes:` section in the structure view and a
  nested tables/indexes items panel (S2).

Reconciles requirements.md (C3 index portion, S2 satisfied)
and CLAUDE.md. 1038 tests passing (+31), clippy clean.
2026-05-16 00:15:55 +00:00

7995 lines
268 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, warn};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ChangeColumnMode, IndexSelector, 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,
}
#[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>>>,
}
/// 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: String,
ty: Type,
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>>,
},
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,
source: Option<String>,
reply: oneshot::Sender<Result<DataResult, 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: String,
ty: Type,
source: Option<String>,
) -> Result<AddColumnResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddColumn {
table,
column,
ty,
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)?
}
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,
source: Option<String>,
) -> Result<DataResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::QueryData {
table,
source,
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\
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,
ty,
source,
reply,
} => {
let _ = reply.send(do_add_column(
conn,
persistence,
source.as_deref(),
&table,
&column,
ty,
));
}
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::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,
source,
reply,
} => {
let _ = reply.send(do_query_data_request(
conn,
persistence,
source.as_deref(),
&table,
));
}
Request::RebuildFromText {
project_path,
source,
reply,
} => {
let _ = reply.send(do_rebuild_from_text(
conn,
persistence,
source.as_deref(),
&project_path,
));
}
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,
// 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,
})
.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
}
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;
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for col in columns {
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");
}
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)?;
{
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 col in columns {
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
.map_err(DbError::from_rusqlite)?;
}
}
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: &str,
ty: Type,
) -> Result<AddColumnResult, DbError> {
let auto_generated = matches!(ty, Type::Serial | Type::ShortId);
if !auto_generated {
return do_add_plain_column(conn, persistence, source, table, column, ty);
}
do_add_auto_generated_column(conn, persistence, source, table, column, ty)
}
/// Plain ALTER-TABLE path for non-auto-generated types.
fn do_add_plain_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
ty: Type,
) -> Result<AddColumnResult, DbError> {
let ddl = format!(
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type};",
tbl = quote_ident(table),
col = quote_ident(column),
sqlite_type = ty.sqlite_strict_type(),
);
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,
column: &str,
ty: Type,
) -> Result<AddColumnResult, DbError> {
use rusqlite::types::Value as RV;
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,
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,
})
}
/// 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,
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 \
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
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 non-PK columns flagged unique
// (ADR-0018 §4). PK columns get UNIQUE implicitly via
// PRIMARY KEY; double-emitting would still be valid SQL
// but generates a redundant index.
if col.unique && !col.primary_key {
clause.push_str(" UNIQUE");
}
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,
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> {
// `pragma_table_info` is a table-valued function in modern
// SQLite; using it as a SELECT lets us bind the table name
// via ? rather than splicing it into a PRAGMA statement.
// We LEFT JOIN our metadata table to recover the user-facing
// type each column was declared as.
let mut stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \
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 = stmt
.query_map([name], |row| {
let user_type_kw: Option<String> = row.get(4)?;
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
Ok(ColumnDescription {
name: row.get(0)?,
user_type,
sqlite_type: row.get(1)?,
notnull: row.get::<_, i64>(2)? != 0,
primary_key: row.get::<_, i64>(3)? != 0,
})
})
.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() {
// pragma_table_info returns no rows for a non-existent
// table, which we surface as a NoSuchTable error so
// describe_table is not silently empty.
warn!(name, "describe_table: no columns (table missing?)");
return Err(DbError::Sqlite {
message: format!("no such table: {name}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
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,
}
}
/// 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,
})
}
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 { column, value } => {
let bound = impl_value_for(&schema, column, value)?;
let mut stmt = conn
.prepare(&format!(
"SELECT rowid FROM {ident} WHERE {col} = ?1;",
ident = quote_ident(table),
col = quote_ident(column),
))
.map_err(DbError::from_rusqlite)?;
let bound_param = bound_to_sqlite_value(&bound);
let rows = stmt
.query_map([&bound_param], |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 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 { column, value } => {
let bound = impl_value_for(&schema, column, value)?;
params.push(bound_to_sqlite_value(&bound));
format!(
" WHERE {col} = ?{n}",
col = quote_ident(column),
n = params.len()
)
}
};
let sql = format!(
"UPDATE {ident} SET {sets}{where_sql};",
ident = quote_ident(table),
sets = set_clauses.join(", "),
);
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)
}
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 mut params: Vec<rusqlite::types::Value> = Vec::new();
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where { column, value } => {
let bound = impl_value_for(&schema, column, value)?;
params.push(bound_to_sqlite_value(&bound));
format!(
" WHERE {col} = ?{n}",
col = quote_ident(column),
n = params.len()
)
}
};
let sql = format!(
"DELETE FROM {ident}{where_sql};",
ident = quote_ident(table),
);
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 diff = before_count - after_count;
if diff > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed: diff,
action: rel.on_delete,
});
rewritten_tables.push(rel.other_table.clone());
}
}
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
})
}
/// Read-only wrapper that adds the `history.log` append for
/// `show data` user commands.
fn do_query_data_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
) -> Result<DataResult, DbError> {
let data = do_query_data(conn, table)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
}
fn do_query_data(conn: &Connection, table: &str) -> 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 cols_csv = column_names
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT {cols} FROM {ident};",
cols = cols_csv,
ident = quote_ident(table),
);
debug!(sql = %sql, "query_data");
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let rows_iter = stmt
.query_map([], |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())),
}
}
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: false,
primary_key: table.primary_key.contains(&c.name),
unique: c.unique,
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 {
name: name.to_string(),
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(), "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(), 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(), 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(), "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(), "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(), "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).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(), "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).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(), "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).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(), "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).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(), "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(), "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(), "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).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(),
"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(), "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(), "CustId".to_string(), Type::Int, None)
.await
.unwrap();
db.add_column("Orders".to_string(), "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(), "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(), "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(), "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(),
"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(), "A".to_string(), Type::Text, None)
.await
.unwrap();
db.add_column("T".to_string(), "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(), "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(), "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).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(),
"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(),
"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(), "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(), "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(), "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(), "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).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(), "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(), "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(), "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(),
"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(), "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(), "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(), "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(), "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(), "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).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(), "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).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(),
"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(), "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(), "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(), "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(), "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(), "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).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).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).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::Where {
column: "id".to_string(),
value: 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).await.unwrap();
assert_eq!(data.rows[0][1], Some("Alicia".to_string()));
assert_eq!(data.rows[1][1], Some("Bob".to_string()));
}
#[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::Where {
column: "id".to_string(),
value: 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).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::Where {
column: "id".to_string(),
value: Value::Number("1".to_string()),
},
None)
.await
.unwrap();
let orders = db.query_data("Orders".to_string(), None).await.unwrap();
assert!(orders.rows.is_empty(), "child rows should be cascaded");
}
#[tokio::test]
async fn query_data_renders_bools_as_words() {
let db = db();
db.create_table(
"Flags".to_string(),
vec![col("id", Type::Serial), col("Active", Type::Bool)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(true)],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(false)],
None)
.await
.unwrap();
let data = db.query_data("Flags".to_string(), None).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).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,
user_type: Some(Type::Serial),
},
ReadColumn {
name: "code".to_string(),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
unique: true,
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(), "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(), "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()]);
}
}