Files
rdbms-playground/src/db.rs
T
claude@clouddev1 8bd43ccadf feat: create m:n relationship convenience command (C4, ADR-0045)
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.

Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).

Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
  candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
  mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
  pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.

Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
2026-06-10 14:26:33 +00:00

14900 lines
530 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, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector,
Operand, Predicate, RelationshipSelector, RowFilter, SqlForeignKey,
};
use crate::dsl::ColumnSpec;
use crate::dsl::shortid;
use crate::dsl::types::Type;
use crate::dsl::value::{Bound, Value, ValueError};
use crate::mode::Mode;
use crate::output_render::{Alignment, render_diagnostic_table};
use crate::type_change;
use crate::persistence::{
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
};
use crate::project::{DATA_DIR, PROJECT_YAML};
use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged};
/// Inbox capacity. The worker is fast enough that this rarely
/// matters; `64` is a generous head-room for bursts.
const REQUEST_CHANNEL_CAPACITY: usize = 64;
/// In-process handle for the database. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Database {
inbox: mpsc::Sender<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>,
/// Table-level composite `UNIQUE (a, b, …)` constraints (ADR-0035
/// §4a.2). Single-column UNIQUE rides on the column itself
/// (`ColumnDescription::unique`); these are the multi-column ones.
pub unique_constraints: Vec<Vec<String>>,
/// Table-level `CHECK (…)` constraints with their optional name
/// (ADR-0035 §4a.3 / §4g), in declaration order.
pub check_constraints: Vec<crate::persistence::TableCheck>,
}
/// Structured payload for rendering one relationship's diagram.
///
/// ADR-0044: the relationship plus both endpoint table structures.
/// Built worker-side; rendered **App-side** (like `QueryPlan`) so the
/// diagram can be width-aware and styled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipDiagramData {
/// The relationship itself (endpoints + referential actions).
pub rel: crate::persistence::RelationshipSchema,
/// FK-holder (the `n` side), drawn on the left.
pub child: TableDescription,
/// Referenced table (the `1` side), drawn on the right.
pub parent: TableDescription,
}
/// 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(s) on the other table (ordered, paired with
/// `local_columns`; one element for single-column — ADR-0043).
pub other_columns: Vec<String>,
/// The column(s) on *this* table.
pub local_columns: Vec<String>,
pub on_delete: ReferentialAction,
pub on_update: ReferentialAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDescription {
pub name: String,
/// The user-facing type the column was declared as, recovered
/// from our internal column-metadata table. Always populated
/// for tables created through the DSL. `None` only for the
/// edge case of a foreign-attached database whose tables we
/// did not create — not achievable in the current flow.
pub user_type: Option<Type>,
/// The SQLite-side type as reported by `PRAGMA table_info`
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
/// fall-back when `user_type` is not available; the UI
/// prefers `user_type` when rendering.
pub sqlite_type: String,
pub notnull: bool,
pub primary_key: bool,
/// Carries a single-column `UNIQUE` constraint (ADR-0029).
/// A PK column is not flagged here — the `primary_key`
/// flag already conveys its implicit uniqueness.
pub unique: bool,
/// The column's `DEFAULT` expression as SQLite reports it,
/// or `None` (ADR-0029).
pub default: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form,
/// or `None` (ADR-0029).
pub check: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DbError {
Sqlite {
message: String,
kind: SqliteErrorKind,
},
Unsupported(String),
InvalidValue(String),
PersistenceFatal {
operation: &'static str,
path: std::path::PathBuf,
message: String,
},
RebuildRowFailed {
table: String,
csv_path: std::path::PathBuf,
row_number: usize,
detail: String,
},
WorkerGone,
Io(String),
}
impl std::fmt::Display for DbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sqlite { message, .. } => f.write_str(&crate::t!(
"db.error.sqlite",
message = message,
)),
Self::Unsupported(detail) => f.write_str(&crate::t!(
"db.error.unsupported",
detail = detail,
)),
Self::InvalidValue(detail) => f.write_str(&crate::t!(
"db.error.invalid_value",
detail = detail,
)),
Self::PersistenceFatal {
operation,
path,
message,
} => f.write_str(&crate::t!(
"db.error.persistence_fatal",
operation = operation,
path = path.display(),
message = message,
)),
Self::RebuildRowFailed {
table,
csv_path,
row_number,
detail,
} => f.write_str(&crate::t!(
"db.error.rebuild_row_failed",
row_number = row_number,
csv_path = csv_path.display(),
table = table,
detail = detail,
)),
Self::WorkerGone => f.write_str(&crate::t!("db.error.worker_gone")),
Self::Io(detail) => f.write_str(&crate::t!(
"db.error.io",
detail = detail,
)),
}
}
}
impl std::error::Error for DbError {}
/// Result of a query / show-data call.
///
/// `None` cells render as NULL; `Some(s)` renders as the
/// string. `column_types` carries the user-facing type per
/// column (per ADR-0016 §2): the renderer uses it for
/// alignment, and future work uses it for type-aware cell
/// styling. `None` only for the edge case of a
/// foreign-attached database we did not create — not
/// achievable in normal use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataResult {
pub table_name: String,
pub columns: Vec<String>,
pub column_types: Vec<Option<Type>>,
pub rows: Vec<Vec<Option<String>>>,
}
/// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2).
///
/// `id` / `parent` form the plan tree (`parent == 0` is a
/// top-level node); `detail` is the engine's verbatim step
/// description (`SCAN Customers`, `SEARCH … USING INDEX …`).
/// The `notused` column the engine also returns is dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExplainRow {
pub id: i64,
pub parent: i64,
pub detail: String,
}
/// A captured query plan (ADR-0028 §2/§3).
///
/// `display_sql` is the standard-SQL form of the explained
/// statement — shown above the plan tree as part of the
/// simple → advanced bridge. `rows` are the plan-tree nodes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryPlan {
pub display_sql: String,
pub rows: Vec<ExplainRow>,
}
/// Outcome of a successful INSERT — a count plus the new row(s)
/// fetched immediately after so the user can see what landed
/// (auto-filled IDs, generated shortids, etc.).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InsertResult {
pub rows_affected: usize,
pub data: DataResult,
}
/// Outcome of a successful `add column …`.
///
/// Carries the post-add structure (used for the auto-show that
/// follows DDL) plus zero or one pre-rendered `[client-side]`
/// note lines (ADR-0018 §9). Only the auto-fill paths
/// (`add column T: x (serial|shortid)` on a non-empty table)
/// produce notes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddColumnResult {
pub description: TableDescription,
pub client_side_notes: Vec<String>,
}
/// Outcome of a successful `drop column …` (ADR-0025).
///
/// `dropped_indexes` names any index removed by `--cascade`
/// because it covered the dropped column. Empty in the common
/// case (no covering index, or none to cascade); the runtime
/// renders one note line per entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DropColumnResult {
pub description: TableDescription,
pub dropped_indexes: Vec<String>,
}
/// Outcome of a successful `change column …` (ADR-0017 §6).
///
/// `description` is the post-rebuild table structure (used for
/// the auto-show that follows DDL). `client_side` carries the
/// pedagogical note that fires whenever the playground rewrote
/// any cell value before storing it — the moment that tells the
/// learner "the tool did this for you; raw SQL would need a
/// `CAST` or application code." `None` when the change was
/// pure metadata (storage class unchanged for every row).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeColumnTypeResult {
pub description: TableDescription,
pub client_side: Option<ClientSideNote>,
}
/// Counts feeding the `[client-side]` success line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientSideNote {
/// Cells whose stored value differs from what the user
/// originally inserted (any non-identity transformation,
/// including pure storage-class changes like
/// `Text("42")` → `Integer(42)`). Auto-filled cells (per
/// ADR-0018) are NOT counted here — they're tracked in
/// `auto_filled` separately so the success summary can
/// distinguish "the tool transformed your data" from "the
/// tool generated values where you had nulls."
pub transformed: usize,
/// Subset of `transformed` where information was discarded
/// (only non-zero when `--force-conversion` was used).
pub lossy: usize,
/// Null cells filled with auto-generated values when the
/// target type carries an auto-generation contract
/// (`serial` / `shortid`). ADR-0018 §3 / §7.
pub auto_filled: usize,
/// What kind of value was auto-generated; drives the note
/// wording (per ADR-0018 §9). `None` when `auto_filled` is
/// zero.
pub auto_fill_kind: Option<AutoFillKind>,
}
/// Whether an auto-fill emitted serial sequence values or
/// generated shortids.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoFillKind {
Serial,
ShortId,
}
/// Outcome of a successful UPDATE — a count plus the rows that
/// matched (and were updated). Captured by rowid so that even an
/// UPDATE which changes the WHERE-target column still finds the
/// post-update rows.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateResult {
pub rows_affected: usize,
pub data: DataResult,
}
/// Outcome of a successful DELETE — the directly-deleted-row
/// count plus any cascade effects observed in child tables.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteResult {
pub rows_affected: usize,
pub cascade: Vec<CascadeEffect>,
/// Rows produced by a `RETURNING` clause (ADR-0033 §5, 3g).
/// Empty (no columns, no rows) when the DELETE had no
/// `RETURNING` — the renderer skips a column-less result, so the
/// non-RETURNING path is unaffected. For SQL `DELETE … RETURNING`
/// these are the rows as they were *before* deletion; the
/// cascade summary surfaces alongside.
pub data: DataResult,
}
/// One observed change in a child table caused by referential
/// action on a parent-side DELETE. Detected by row-count diffing
/// the child table immediately before and after the delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CascadeEffect {
pub relationship_name: String,
pub child_table: String,
pub rows_changed: i64,
pub action: ReferentialAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteErrorKind {
/// `UNIQUE` constraint, including duplicate primary key.
UniqueViolation,
/// Referenced or operated-on table does not exist.
NoSuchTable,
/// Operated-on column does not exist.
NoSuchColumn,
/// Object (table, index, etc.) already exists.
AlreadyExists,
/// Catch-all.
Other,
}
impl DbError {
/// User-visible rendering of this error.
///
/// Routes through the H1 friendly-error layer
/// ([`crate::friendly::translate_error`], ADR-0019). With
/// no context the translator falls back to abstract wording
/// — for operation-tailored output, callers should
/// construct a [`crate::friendly::TranslateContext`] and
/// call the translator directly. This method exists for the
/// many callsites where context isn't readily available
/// (fatal-banner paths, fallback render paths) and the
/// abstract wording is acceptable.
#[must_use]
pub fn friendly_message(&self) -> String {
let ctx = crate::friendly::TranslateContext::default();
crate::friendly::translate_error(self, &ctx).render()
}
fn from_rusqlite(err: rusqlite::Error) -> Self {
let message = err.to_string();
let kind = classify_sqlite_error(&err, &message);
Self::Sqlite { message, kind }
}
fn from_persistence(err: PersistenceError) -> Self {
Self::PersistenceFatal {
operation: err.operation(),
path: err.path().to_path_buf(),
message: err.to_string(),
}
}
/// Whether this error means the application cannot
/// continue and must quit (per ADR-0015 §8). The runtime
/// surfaces these as fatal banners.
#[must_use]
pub const fn is_fatal(&self) -> bool {
matches!(self, Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. })
}
}
fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
use rusqlite::ErrorCode;
if let rusqlite::Error::SqliteFailure(code, _) = err
&& code.code == ErrorCode::ConstraintViolation
{
return SqliteErrorKind::UniqueViolation;
}
let lowered = message.to_ascii_lowercase();
if lowered.contains("no such table") {
SqliteErrorKind::NoSuchTable
} else if lowered.contains("no such column") {
SqliteErrorKind::NoSuchColumn
} else if lowered.contains("already exists") {
SqliteErrorKind::AlreadyExists
} else {
SqliteErrorKind::Other
}
}
/// Internal request type — kept private so the channel protocol
/// is not part of the public API.
#[derive(Debug)]
enum Request {
CreateTable {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
/// structurally through `do_create_table`; `if_not_exists` turns
/// an existing table into a no-op (`CreateOutcome::Skipped`, no
/// snapshot) instead of an error (ADR-0035 §4).
SqlCreateTable {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
check_constraints: Vec<String>,
foreign_keys: Vec<SqlForeignKey>,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
},
DropTable {
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<(), DbError>>,
},
/// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
/// Executes through `do_drop_table`; `if_exists` turns an absent
/// table into a no-op (`DropOutcome::Skipped`, no snapshot).
SqlDropTable {
name: String,
if_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<DropOutcome, DbError>>,
},
/// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name>` (ADR-0035 §4d).
/// Executes through `do_drop_index`; `if_exists` turns an absent
/// index into a no-op (`DropIndexOutcome::Skipped`, no snapshot).
SqlDropIndex {
name: String,
if_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<DropIndexOutcome, DbError>>,
},
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
/// (ADR-0035 §4d). Executes through `do_add_index` (with `unique`);
/// `if_not_exists` turns an existing index name into a no-op
/// (`CreateIndexOutcome::Skipped`, no snapshot).
SqlCreateIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
source: Option<String>,
reply: oneshot::Sender<Result<CreateIndexOutcome, DbError>>,
},
AddColumn {
table: String,
column: ColumnSpec,
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>>,
},
/// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
RenameTable {
table: 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>>,
},
/// List every item of a schema kind (tables / relationships /
/// indexes) as pre-formatted display lines for the `show
/// <kind>` commands (V5). Read-only; formats in the worker
/// from the same helpers the items panel and describe view use.
ShowList {
kind: crate::dsl::command::ShowListKind,
/// `None` lists all items of the kind; `Some(name)` shows
/// one named relationship/index's detail (V5a).
name: Option<String>,
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
/// Structured data to render one relationship's diagram (ADR-0044
/// §6): the relationship + both endpoint table structures, or
/// `None` if no relationship by that name exists.
ShowRelationship {
name: String,
reply: oneshot::Sender<Result<Option<RelationshipDiagramData>, DbError>>,
},
DescribeTable {
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
AddRelationship {
name: Option<String>,
parent_table: String,
parent_columns: Vec<String>,
child_table: String,
child_columns: Vec<String>,
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
CreateM2nRelationship {
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropRelationship {
selector: RelationshipSelector,
source: Option<String>,
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
},
/// Create an index on a table (ADR-0025).
AddIndex {
name: Option<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Drop an index by name or by table + column set (ADR-0025).
DropIndex {
selector: IndexSelector,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Add a column-level constraint to an existing column
/// (ADR-0029 §2.2). The post-rebuild `TableDescription`
/// flows back through the standard auto-show path.
AddConstraint {
table: String,
column: String,
constraint: Constraint,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// Remove a column-level constraint from an existing column
/// (ADR-0029 §2.2).
DropConstraint {
table: String,
column: String,
kind: ConstraintKind,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set a
/// column's default to raw SQL text (ADR-0035 Amendment 2). Distinct
/// from `AddConstraint(Default(Value))` because the advanced default
/// is raw `sql_expr` text with no typed `Value`.
SetColumnDefault {
table: String,
column: String,
default_sql: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
/// table-level CHECK, named or unnamed (ADR-0035 §4g).
AlterAddTableCheck {
table: String,
name: Option<String>,
expr_sql: String,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
/// constraint (ADR-0035 §4g).
AlterAddUnique {
table: String,
columns: Vec<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
/// CHECK or a named FK (ADR-0035 §4g).
AlterDropConstraint {
table: String,
name: String,
source: Option<String>,
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
},
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
/// REFERENCES …` — a relationship on an existing table (ADR-0035 §4g).
AlterAddForeignKey {
child_table: String,
name: Option<String>,
fk: Box<SqlForeignKey>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
Insert {
table: String,
columns: Option<Vec<String>>,
values: Vec<Value>,
source: Option<String>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
Update {
table: String,
assignments: Vec<(String, Value)>,
filter: RowFilter,
source: Option<String>,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
Delete {
table: String,
filter: RowFilter,
source: Option<String>,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
QueryData {
table: String,
filter: Option<Expr>,
limit: Option<u64>,
source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// Run a SQL `SELECT` typed by the user in advanced mode
/// (ADR-0030 §6, ADR-0031). The grammar walker has already
/// validated `sql` is in the supported subset; the worker
/// prepares and runs the statement and returns the rows as
/// a [`DataResult`] (with no playground type information per
/// ADR-0030 §6 — computed columns render with neutral
/// alignment). `source` is the literal submitted line,
/// appended to `history.log` for replay (ADR-0030 §11).
RunSelect {
sql: String,
source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// Run a validated SQL `INSERT` typed in advanced mode
/// (ADR-0033 §1, sub-phase 3b). The grammar walker has
/// validated `sql` is in the supported subset; the worker
/// executes it as text, re-persists the target table's CSV
/// (ADR-0030 §11), and appends the literal line to
/// `history.log`. `target_table` comes from the parse so the
/// worker re-persists the right CSV without re-parsing.
RunSqlInsert {
sql: String,
source: Option<String>,
target_table: String,
listed_columns: Vec<String>,
row_source: String,
returning: bool,
/// Captured literal `VALUES` (per row, per position; `None` =
/// expression) for app-level type validation before the verbatim
/// insert (ADR-0036 Phase 1).
literal_rows: Vec<Vec<Option<Value>>>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
},
/// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
/// worker executes `sql` as text, re-persists `target_table`'s
/// CSV (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlUpdate {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
/// Captured literal `SET col = <literal>` values (`(col, None)` =
/// expression RHS) for app-level type validation before the
/// verbatim update (ADR-0036 Phase 2).
set_literals: Vec<(String, Option<Value>)>,
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
},
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
/// worker executes `sql` as text, detects FK cascade by
/// row-count diffing the inbound children (Amendment 2),
/// re-persists `target_table`'s CSV plus every cascade-affected
/// child (ADR-0030 §11), and appends the literal line to
/// `history.log`.
RunSqlDelete {
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
},
/// Capture the query plan for an explainable command via
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
/// never executes it, so this is read-only even for the
/// destructive variants.
ExplainPlan {
query: Command,
reply: oneshot::Sender<Result<QueryPlan, DbError>>,
},
/// Rebuild the database from `project.yaml` + `data/`
/// (ADR-0015 §7). Used by the runtime when the `.db` file
/// is missing on project open and by the explicit
/// `rebuild` app-level command (Iteration 4).
RebuildFromText {
project_path: std::path::PathBuf,
source: Option<String>,
reply: oneshot::Sender<Result<(), DbError>>,
},
/// Read both directions of FK relationships for `table`.
/// Returns `(outbound, inbound)` — outbound = rows where
/// `table` is the child (has FK columns pointing elsewhere);
/// inbound = rows where `table` is the parent (referenced
/// by other tables). Used by the friendly-error layer's
/// runtime enrichment (ADR-0019 §6).
ReadRelationships {
table: String,
reply: oneshot::Sender<RelationshipsReply>,
},
/// Find rows in `table` where `column` matches `value`.
/// Capped at `limit` rows. Used by the friendly-error
/// layer's row-pinpoint diagnostic (ADR-0019 §6, ADR-0017 §7).
/// Best-effort: returns empty rows on any failure (no row
/// matched, schema gone, type mismatch on bind, etc.).
FindRowsMatching {
table: String,
column: String,
value: Value,
limit: usize,
reply: oneshot::Sender<Result<DataResult, DbError>>,
},
/// List schema entity names for a given identifier slot
/// (ADR-0022 §9). Used by the completion engine to offer
/// candidates for `TableName` / `Column` /
/// `RelationshipName` slots. `NewName` is rejected at
/// the caller — schema has nothing to offer for new
/// names — and never reaches the worker.
///
/// Returns names in stable (alphabetical) order, no
/// duplicates. The reply is small even for projects with
/// hundreds of tables/columns.
ListNamesFor {
source: crate::dsl::grammar::IdentSource,
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
/// Replies with the metadata of the command that was undone, or
/// `None` if there is nothing to undo (or undo is disabled).
Undo {
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
},
/// Re-apply the most recently undone snapshot. `None` if there is
/// nothing to redo.
Redo {
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
},
/// Read — without restoring — the snapshot `undo` would restore.
/// Used to build the confirmation prompt. `None` if the ring is
/// empty or undo is disabled.
PeekUndo {
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
},
/// Read — without restoring — the snapshot `redo` would restore.
PeekRedo {
reply: oneshot::Sender<Result<Option<SnapshotMeta>, DbError>>,
},
/// Open a batch (ADR-0006 Amendment 1): take one boundary
/// snapshot for the whole batch and suppress per-command
/// snapshots until `EndBatch`. Used by `replay` so a multi-command
/// replay is a single undo step. `source` is the batch command.
BeginBatch {
source: Option<String>,
reply: oneshot::Sender<()>,
},
/// Close a batch: finalise the boundary snapshot into the ring if
/// any mutation committed during the batch, else discard it.
EndBatch {
reply: oneshot::Sender<()>,
},
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Sent at boot / project-switch to seed the mode and
/// whenever the user changes mode mid-session.
SetMode {
mode: Mode,
reply: oneshot::Sender<Result<(), 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, false)
}
/// Open a database with per-command persistence wired in
/// (ADR-0015 §6). Every successful user-issued mutation
/// writes through to `project.yaml`, the affected
/// `data/<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), false)
}
/// Open with per-command persistence *and* the undo/snapshot ring
/// (ADR-0006 Amendment 1). `undo_enabled` is `false` under the
/// `--no-undo` CLI flag, in which case no snapshots are taken.
pub fn open_with_persistence_and_undo<P: AsRef<Path>>(
path: P,
persistence: Persistence,
undo_enabled: bool,
) -> Result<Self, DbError> {
Self::open_inner(path, Some(persistence), undo_enabled)
}
fn open_inner<P: AsRef<Path>>(
path: P,
persistence: Option<Persistence>,
undo_enabled: bool,
) -> 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)?;
// The undo ring needs the project directory; it is only
// available when persistence is wired and undo is enabled.
let snapshots = if undo_enabled {
persistence
.as_ref()
.map(|p| SnapshotStore::new(p.project_path(), DEFAULT_RING_CAPACITY))
} else {
None
};
if let Some(store) = &snapshots {
// Sweep crash leftovers (`.staging/`, orphan payloads).
if let Err(e) = store.cleanup() {
warn!(error = %e, "undo snapshot cleanup on open failed");
}
info!("undo snapshots enabled");
}
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
thread::Builder::new()
.name("rdbms-db-worker".to_string())
.spawn(move || worker_loop(conn, persistence, snapshots, rx))
.map_err(|e| DbError::Io(e.to_string()))?;
Ok(Self { inbox: tx })
}
pub async fn create_table(
&self,
name: String,
columns: Vec<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)?
}
/// Advanced-mode SQL `CREATE TABLE` (ADR-0035 §1, 4a). Executes
/// structurally; returns whether the table was created or skipped
/// (the `IF NOT EXISTS` no-op, ADR-0035 §4).
#[allow(clippy::too_many_arguments)]
pub async fn sql_create_table(
&self,
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
unique_constraints: Vec<Vec<String>>,
check_constraints: Vec<String>,
foreign_keys: Vec<SqlForeignKey>,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlCreateTable {
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_table(&self, name: String, source: Option<String>) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropTable { name, source, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c).
/// Returns whether the table was dropped or skipped (the `IF EXISTS`
/// no-op on an absent table).
pub async fn sql_drop_table(
&self,
name: String,
if_exists: bool,
source: Option<String>,
) -> Result<DropOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlDropTable {
name,
if_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `DROP INDEX [IF EXISTS]` (ADR-0035 §4d).
/// Returns whether the index was dropped (with the affected table's
/// structure) or skipped (the `IF EXISTS` no-op on an absent index).
pub async fn sql_drop_index(
&self,
name: String,
if_exists: bool,
source: Option<String>,
) -> Result<DropIndexOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlDropIndex {
name,
if_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Advanced-mode SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS]`
/// (ADR-0035 §4d). Returns whether the index was created (with the
/// affected table's structure) or skipped (the `IF NOT EXISTS` no-op
/// on an existing index name, carrying the resolved name).
pub async fn sql_create_index(
&self,
name: Option<String>,
table: String,
columns: Vec<String>,
unique: bool,
if_not_exists: bool,
source: Option<String>,
) -> Result<CreateIndexOutcome, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_column(
&self,
table: String,
column: ColumnSpec,
source: Option<String>,
) -> Result<AddColumnResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddColumn {
table,
column,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_column(
&self,
table: String,
column: String,
cascade: bool,
source: Option<String>,
) -> Result<DropColumnResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropColumn {
table,
column,
cascade,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_index(
&self,
name: Option<String>,
table: String,
columns: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddIndex {
name,
table,
columns,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_index(
&self,
selector: IndexSelector,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropIndex {
selector,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Add a column-level constraint to an existing column
/// (ADR-0029 §2.2).
pub async fn add_constraint(
&self,
table: String,
column: String,
constraint: Constraint,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddConstraint {
table,
column,
constraint,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Remove a column-level constraint from an existing column
/// (ADR-0029 §2.2).
pub async fn drop_constraint(
&self,
table: String,
column: String,
kind: ConstraintKind,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropConstraint {
table,
column,
kind,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set the
/// column's default to raw SQL text (ADR-0035 Amendment 2).
pub async fn set_column_default(
&self,
table: String,
column: String,
default_sql: String,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SetColumnDefault {
table,
column,
default_sql,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
/// table-level CHECK (ADR-0035 §4g).
pub async fn alter_add_table_check(
&self,
table: String,
name: Option<String>,
expr_sql: String,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddTableCheck {
table,
name,
expr_sql,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
/// constraint (ADR-0035 §4g).
pub async fn alter_add_unique(
&self,
table: String,
columns: Vec<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddUnique {
table,
columns,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
/// CHECK or a named FK (ADR-0035 §4g).
pub async fn alter_drop_constraint(
&self,
table: String,
name: String,
source: Option<String>,
) -> Result<Option<TableDescription>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterDropConstraint {
table,
name,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
/// REFERENCES …` — add a relationship to an existing table (ADR-0035
/// §4g).
pub async fn alter_add_foreign_key(
&self,
child_table: String,
name: Option<String>,
fk: SqlForeignKey,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AlterAddForeignKey {
child_table,
name,
fk: Box::new(fk),
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn rename_column(
&self,
table: String,
old: String,
new: String,
source: Option<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)?
}
/// `ALTER TABLE <table> RENAME TO <new>` (ADR-0035 §6, 4h).
pub async fn rename_table(
&self,
table: String,
new: String,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RenameTable {
table,
new,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn change_column_type(
&self,
table: String,
column: String,
ty: Type,
mode: ChangeColumnMode,
source: Option<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)?
}
/// Pre-formatted display lines for `show tables` /
/// `show relationships` / `show indexes` (V5). Read-only.
pub async fn show_list(
&self,
kind: crate::dsl::command::ShowListKind,
name: Option<String>,
) -> Result<Vec<String>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ShowList { kind, name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Structured data to render one relationship's diagram (ADR-0044):
/// the relationship + both endpoint table structures, or `None` if
/// no relationship by that name exists.
pub async fn show_relationship(
&self,
name: String,
) -> Result<Option<RelationshipDiagramData>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ShowRelationship { name, 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_columns: Vec<String>,
child_table: String,
child_columns: Vec<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_columns,
child_table,
child_columns,
on_delete,
on_update,
create_fk,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Generate a junction table for an m:n relationship between
/// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
/// step (the junction + both relationships are built in a single
/// `do_create_table`).
pub async fn create_m2n_relationship(
&self,
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_relationship(
&self,
selector: RelationshipSelector,
source: Option<String>,
) -> Result<Option<TableDescription>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropRelationship {
selector,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn insert(
&self,
table: String,
columns: Option<Vec<String>>,
values: Vec<Value>,
source: Option<String>,
) -> Result<InsertResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Insert {
table,
columns,
values,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn update(
&self,
table: String,
assignments: Vec<(String, Value)>,
filter: RowFilter,
source: Option<String>,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Update {
table,
assignments,
filter,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn delete(
&self,
table: String,
filter: RowFilter,
source: Option<String>,
) -> Result<DeleteResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Delete {
table,
filter,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Rebuild the database from `project.yaml` + `data/`
/// (ADR-0015 §7).
///
/// Called by the runtime on a missing `.db` at startup
/// (with `source = None`, no history entry) and by the
/// explicit `rebuild` app-level command (with
/// `source = Some("rebuild")`, which appends to
/// `history.log` on success).
pub async fn rebuild_from_text(
&self,
project_path: std::path::PathBuf,
source: Option<String>,
) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RebuildFromText {
project_path,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn query_data(
&self,
table: String,
filter: Option<Expr>,
limit: Option<u64>,
source: Option<String>,
) -> Result<DataResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::QueryData {
table,
filter,
limit,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `SELECT` and return the rows
/// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated
/// statement text; `source` is the literal submitted line
/// for `history.log`.
pub async fn run_select(
&self,
sql: String,
source: Option<String>,
) -> Result<DataResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSelect { sql, source, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `INSERT` and return the affected-row
/// count plus the inserted rows (ADR-0033 §1, sub-phase 3b).
/// `sql` is the grammar-validated statement text; `source` is
/// the literal submitted line for `history.log`; `target_table`
/// is the parsed target whose CSV is re-persisted.
/// Run a grammar-validated SQL `INSERT` with **no** captured literals
/// (no app-level value validation — the verbatim ADR-0033 path). Used
/// by worker-level callers that build the statement directly. The
/// runtime, which has a parsed command, uses
/// [`Self::run_sql_insert_with_literals`] instead so the literals are
/// validated (ADR-0036 Phase 1).
pub async fn run_sql_insert(
&self,
sql: String,
source: Option<String>,
target_table: String,
listed_columns: Vec<String>,
row_source: String,
returning: bool,
) -> Result<InsertResult, DbError> {
self.run_sql_insert_with_literals(
sql,
source,
target_table,
listed_columns,
row_source,
returning,
Vec::new(),
)
.await
}
/// As [`Self::run_sql_insert`], plus the literal `VALUES` captured at
/// parse (per row, per position; `None` = expression) so the worker
/// can validate each literal against its column type before the
/// (still verbatim) insert and the error layer can name the offending
/// value (ADR-0036 Phase 1).
#[allow(clippy::too_many_arguments)]
pub async fn run_sql_insert_with_literals(
&self,
sql: String,
source: Option<String>,
target_table: String,
listed_columns: Vec<String>,
row_source: String,
returning: bool,
literal_rows: Vec<Vec<Option<Value>>>,
) -> Result<InsertResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlInsert {
sql,
source,
target_table,
listed_columns,
row_source,
returning,
literal_rows,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `UPDATE` with **no** captured literals (no
/// app-level value validation — the verbatim ADR-0033 path). Used by
/// worker-level callers that build the statement directly. The
/// runtime, which has a parsed command, uses
/// [`Self::run_sql_update_with_literals`] instead so the `SET`
/// literals are validated (ADR-0036 Phase 2). `sql` is the
/// grammar-validated statement text; `source` is the literal
/// submitted line for `history.log`; `target_table` is the parsed
/// target whose CSV is re-persisted.
pub async fn run_sql_update(
&self,
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
) -> Result<UpdateResult, DbError> {
self.run_sql_update_with_literals(sql, source, target_table, returning, Vec::new())
.await
}
/// As [`Self::run_sql_update`], plus the literal `SET` values captured
/// at parse (`(col, None)` = expression RHS) so the worker can
/// validate each literal against its column type before the (still
/// verbatim) update and the error layer can name the offending value
/// (ADR-0036 Phase 2).
pub async fn run_sql_update_with_literals(
&self,
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
set_literals: Vec<(String, Option<Value>)>,
) -> Result<UpdateResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlUpdate {
sql,
source,
target_table,
returning,
set_literals,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Run a validated SQL `DELETE` and return the affected-row
/// count plus any cascade effects (ADR-0033 §1/§7, sub-phase
/// 3f). `sql` is the grammar-validated statement text; `source`
/// is the literal submitted line for `history.log`;
/// `target_table` is the parsed target whose CSV (and whose
/// cascade-affected children's CSVs) are re-persisted.
pub async fn run_sql_delete(
&self,
sql: String,
source: Option<String>,
target_table: String,
returning: bool,
) -> Result<DeleteResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::RunSqlDelete {
sql,
source,
target_table,
returning,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). The wrapped command is not executed —
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
/// locate the rows — so this is safe even for `update` /
/// `delete`.
pub async fn explain_query_plan(
&self,
query: Command,
) -> Result<QueryPlan, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ExplainPlan { query, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Read both directions of FK relationships for `table`.
/// Used by the runtime's friendly-error enrichment to
/// resolve parent / child table names (ADR-0019 §6).
pub async fn read_relationships(
&self,
table: String,
) -> RelationshipsReply {
let (reply, recv) = oneshot::channel();
self.send(Request::ReadRelationships { table, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Pinpoint rows in `table` where `column` matches `value`.
/// Used by the runtime's friendly-error enrichment to
/// surface offending rows after a UNIQUE / FK violation
/// (ADR-0019 §6, ADR-0017 §7). Capped at `limit`.
pub async fn find_rows_matching(
&self,
table: String,
column: String,
value: Value,
limit: usize,
) -> Result<DataResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::FindRowsMatching {
table,
column,
value,
limit,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// List schema entity names for an identifier source
/// (ADR-0022 §9, ADR-0024 §architecture).
///
/// Returns alphabetised, deduplicated names suitable for
/// the completion menu:
/// - `IdentSource::Tables` → user tables (filters
/// `__rdbms_*` internal tables);
/// - `IdentSource::Columns` → distinct column names
/// across all user tables (v1 simplification — no
/// table-context binding);
/// - `IdentSource::Relationships` → relationship names
/// from the metadata table;
/// - `IdentSource::NewName`, `Types`, `Free` → returns
/// `Ok(vec![])` immediately without a worker round-trip
/// (the user invents these names, or the source is
/// synthetic).
pub async fn list_names_for(
&self,
source: crate::dsl::grammar::IdentSource,
) -> Result<Vec<String>, DbError> {
if !source.completes_from_schema() {
return Ok(Vec::new());
}
let (reply, recv) = oneshot::channel();
self.send(Request::ListNamesFor { source, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
/// `Ok(Some(meta))` reports the command that was undone;
/// `Ok(None)` means nothing to undo (or undo is disabled).
pub async fn undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Undo { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Re-apply the most recently undone snapshot. `Ok(None)` means
/// nothing to redo.
pub async fn redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Redo { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Metadata of the snapshot `undo` would restore, without
/// restoring it — for the confirmation prompt.
pub async fn peek_undo(&self) -> Result<Option<SnapshotMeta>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::PeekUndo { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Metadata of the snapshot `redo` would restore.
pub async fn peek_redo(&self) -> Result<Option<SnapshotMeta>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::PeekRedo { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Open a batch so a multi-command operation (`replay`, future
/// batch commands) records a single undo step (ADR-0006
/// Amendment 1). Pair with [`Database::end_batch`]. `source` is
/// the batch command text recorded on the boundary snapshot.
pub async fn begin_batch(&self, source: Option<String>) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::BeginBatch { source, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)
}
/// Close a batch opened with [`Database::begin_batch`]. The
/// boundary snapshot is kept iff a mutation committed during the
/// batch.
pub async fn end_batch(&self) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::EndBatch { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)
}
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Idempotent and cheap; a no-op for databases opened
/// without persistence.
pub async fn set_mode(&self, mode: Mode) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SetMode { mode, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
}
}
/// Internal table tracking the user-facing type each column was
/// declared with. The structure view consults this so users see
/// the names they typed (`serial`, `date`) rather than SQLite's
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
/// promise. Track 2's project file format is the long-term home
/// for this metadata; this table is the in-database mirror that
/// makes round-trip rendering work today.
const META_TABLE: &str = "__rdbms_playground_columns";
const REL_TABLE: &str = "__rdbms_playground_relationships";
/// Single-row key/value table that carries project metadata
/// the YAML serializer needs to round-trip — currently
/// `created_at`. Created on first connect and only ever
/// written by us; the user never touches it directly.
const META_PROJECT_TABLE: &str = "__rdbms_playground_meta";
/// Table-level `CHECK (<expr>)` constraints (ADR-0035 §4a.3). The
/// engine exposes no PRAGMA for CHECK constraints, so — unlike UNIQUE /
/// PK / FK, which are read back from PRAGMA — a table-level CHECK has no
/// engine-readable home and this table is its source of truth. One row
/// per CHECK, ordered by `seq` (declaration order).
const CHECK_TABLE: &str = "__rdbms_playground_table_checks";
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!(
"PRAGMA foreign_keys = ON;\n\
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\
check_expr TEXT,\n\
PRIMARY KEY (table_name, column_name)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\
name TEXT NOT NULL UNIQUE,\n\
parent_table TEXT NOT NULL,\n\
parent_column TEXT NOT NULL,\n\
child_table TEXT NOT NULL,\n\
child_column TEXT NOT NULL,\n\
on_delete TEXT NOT NULL,\n\
on_update TEXT NOT NULL,\n\
PRIMARY KEY (child_table, child_column)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {CHECK_TABLE} (\n\
table_name TEXT NOT NULL,\n\
seq INTEGER NOT NULL,\n\
check_expr TEXT NOT NULL,\n\
name TEXT,\n\
PRIMARY KEY (table_name, seq)\n\
) STRICT;\n\
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
key TEXT NOT NULL PRIMARY KEY,\n\
value TEXT NOT NULL\n\
) STRICT;"
))?;
// Seed `created_at` once. INSERT OR IGNORE keeps the value
// stable across re-opens of the same database.
conn.execute(
&format!(
"INSERT OR IGNORE INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1)"
),
[iso8601_now()],
)?;
Ok(())
}
/// Current UTC time as ISO-8601 with second precision.
/// Duplicates the helper in `persistence::history` rather
/// than depending on it, since this code path runs at
/// connection setup before the rest of the worker is up.
fn iso8601_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let day_secs = secs.rem_euclid(86_400);
let h = day_secs / 3600;
let m = (day_secs % 3600) / 60;
let s = day_secs % 60;
let days = secs.div_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m_cal = if mp < 10 { mp + 3 } else { mp - 9 };
let y_cal = if m_cal <= 2 { y + 1 } else { y };
format!("{y_cal:04}-{m_cal:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn worker_loop(
conn: Connection,
persistence: Option<Persistence>,
snapshots: Option<SnapshotStore>,
mut rx: mpsc::Receiver<Request>,
) {
info!("db worker started");
// `conn` must be mutable: restoring a snapshot (undo/redo) writes
// into the live connection via the backup API (`&mut`).
let mut conn = conn;
let snap = snapshots.as_ref();
let mut batch = BatchState::default();
while let Some(req) = rx.blocking_recv() {
// Undo/redo/peek/batch are handled here: undo/redo need
// `&mut conn` for the restore, and batch state lives across
// requests. Everything else goes to `handle_request`, which
// brackets mutations with a pre-op snapshot.
match req {
Request::Undo { reply } => {
let _ = reply.send(do_undo(snap, &mut conn));
}
Request::Redo { reply } => {
let _ = reply.send(do_redo(snap, &mut conn));
}
Request::PeekUndo { reply } => {
let _ = reply.send(peek_undo_op(snap));
}
Request::PeekRedo { reply } => {
let _ = reply.send(peek_redo_op(snap));
}
Request::BeginBatch { source, reply } => {
begin_batch(snap, &conn, &mut batch, source.as_deref());
let _ = reply.send(());
}
Request::EndBatch { reply } => {
end_batch(snap, &mut batch);
let _ = reply.send(());
}
// ADR-0015 mode-restore amendment (issue #14): record the
// current input mode so `project.yaml` reflects it. We
// persist immediately (not just update the in-memory
// value) so a mode change followed by quit — with no
// intervening command — is still saved.
Request::SetMode { mode, reply } => {
let result = persistence.as_ref().map_or(Ok(()), |p| {
p.set_mode(mode);
persist_current_mode(&conn, p)
});
let _ = reply.send(result);
}
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
}
}
info!("db worker exiting");
}
/// Worker-side undo bracketing state for the request stream.
/// `active` is set between `BeginBatch`/`EndBatch` so per-command
/// snapshots are suppressed in favour of one boundary snapshot for
/// the whole batch (ADR-0006 Amendment 1).
#[derive(Default)]
struct BatchState {
active: bool,
dirty: bool,
staged: Option<Staged>,
}
fn snapshot_to_db_error(e: SnapshotError) -> DbError {
DbError::Io(e.to_string())
}
/// Stage a pre-mutation snapshot, never failing the user's command if
/// the snapshot itself can't be taken (the real persistence is the
/// durable state — the snapshot is a best-effort safety net). Returns
/// `None` when undo is off, the command has no user `source` (an
/// internal op, e.g. open-time rebuild — not undoable), or staging
/// failed.
fn stage_pre_mutation(
snap: Option<&SnapshotStore>,
conn: &Connection,
source: Option<&str>,
) -> Option<Staged> {
let store = snap?;
let src = source?;
match store.stage(conn, src) {
Ok(staged) => Some(staged),
Err(e) => {
warn!(error = %e, "could not stage undo snapshot; command proceeds without undo");
None
}
}
}
/// Run a mutating handler with undo bracketing: stage before, then
/// finalise on success / discard on failure — or, inside a batch,
/// just mark the batch dirty so its single boundary snapshot is kept.
/// The command result is always sent; snapshot bookkeeping never
/// fails the user's actual work.
fn snapshot_then<T>(
snap: Option<&SnapshotStore>,
batch: &mut BatchState,
conn: &Connection,
source: Option<&str>,
reply: oneshot::Sender<Result<T, DbError>>,
run: impl FnOnce() -> Result<T, DbError>,
) {
let staged = if batch.active {
None
} else {
stage_pre_mutation(snap, conn, source)
};
let result = run();
let committed = result.is_ok();
if batch.active {
if committed {
batch.dirty = true;
}
} else if let Some(store) = snap {
let outcome = match staged {
Some(st) if committed => store.finalize(st).map(|_| ()),
Some(st) => store.discard(st),
// No snapshot was staged. If this is a committed user
// mutation (source present → staging FAILED, not just an
// internal op), the redo stack is now stale and must be
// cleared, or a later `redo` would silently discard this
// work. `finalize` would have done this; it didn't run.
None if committed && source.is_some() => store.clear_redo(),
None => Ok(()),
};
if let Err(e) = outcome {
warn!(error = %e, "undo snapshot bookkeeping failed (command already applied)");
}
}
let _ = reply.send(result);
}
/// Open a batch: one boundary snapshot, then suppress per-command
/// snapshots until `end_batch`.
fn begin_batch(
snap: Option<&SnapshotStore>,
conn: &Connection,
batch: &mut BatchState,
source: Option<&str>,
) {
if batch.active {
warn!("BeginBatch while a batch is active; ignoring (no nested batches)");
return;
}
batch.staged = stage_pre_mutation(snap, conn, source);
batch.active = true;
batch.dirty = false;
}
/// Close a batch: keep the boundary snapshot iff a mutation committed
/// during it, else discard it (an all-skips batch leaves no undo step).
fn end_batch(snap: Option<&SnapshotStore>, batch: &mut BatchState) {
if !batch.active {
warn!("EndBatch with no active batch; ignoring");
return;
}
if let Some(store) = snap {
let outcome = match batch.staged.take() {
Some(st) if batch.dirty => store.finalize(st).map(|_| ()),
Some(st) => store.discard(st),
// Boundary snapshot failed to stage but mutations
// committed: clear the now-stale redo stack (same
// data-loss guard as the per-command path).
None if batch.dirty => store.clear_redo(),
None => Ok(()),
};
if let Err(e) = outcome {
warn!(error = %e, "batch undo snapshot bookkeeping failed");
}
}
batch.active = false;
batch.dirty = false;
}
fn do_undo(
snap: Option<&SnapshotStore>,
conn: &mut Connection,
) -> Result<Option<SnapshotMeta>, DbError> {
snap.map_or(Ok(None), |store| {
store.undo(conn).map_err(snapshot_to_db_error)
})
}
fn do_redo(
snap: Option<&SnapshotStore>,
conn: &mut Connection,
) -> Result<Option<SnapshotMeta>, DbError> {
snap.map_or(Ok(None), |store| {
store.redo(conn).map_err(snapshot_to_db_error)
})
}
fn peek_undo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
snap.map_or(Ok(None), |s| s.peek_undo().map_err(snapshot_to_db_error))
}
fn peek_redo_op(snap: Option<&SnapshotStore>) -> Result<Option<SnapshotMeta>, DbError> {
snap.map_or(Ok(None), |s| s.peek_redo().map_err(snapshot_to_db_error))
}
fn handle_request(
conn: &Connection,
persistence: Option<&Persistence>,
snap: Option<&SnapshotStore>,
batch: &mut BatchState,
req: Request,
) {
match req {
Request::CreateTable {
name,
columns,
primary_key,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table(
conn,
persistence,
source.as_deref(),
&name,
&columns,
&primary_key,
&[],
&[],
&[],
));
}
Request::SqlCreateTable {
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
source,
reply,
} => {
// `IF NOT EXISTS` on an existing table is a no-op: reply
// `Skipped` with the existing structure and take **no**
// snapshot (there is nothing to undo). The submitted line is
// still journalled — like other read-only / no-op commands
// (`show table`), it belongs in the complete journal
// (ADR-0034). ADR-0035 §4.
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
let result = do_describe_table(conn, &name).and_then(|desc| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateOutcome::Skipped(desc))
});
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_table(
conn,
persistence,
source.as_deref(),
&name,
&columns,
&primary_key,
&unique_constraints,
&check_constraints,
&foreign_keys,
)
.map(CreateOutcome::Created)
});
}
}
Request::DropTable {
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_table(conn, persistence, source.as_deref(), &name)
});
}
Request::SqlDropTable {
name,
if_exists,
source,
reply,
} => {
// `IF EXISTS` on an absent table is a no-op: reply `Skipped`
// and take **no** snapshot (nothing to undo). The submitted
// line is still journalled — like the `CREATE TABLE IF NOT
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(DropOutcome::Skipped)
})();
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_table(conn, persistence, source.as_deref(), &name)
.map(|()| DropOutcome::Dropped)
});
}
}
Request::AddColumn {
table,
column,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column(
conn,
persistence,
source.as_deref(),
&table,
&column,
));
}
Request::DropColumn {
table,
column,
cascade,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column(
conn,
persistence,
source.as_deref(),
&table,
&column,
cascade,
));
}
Request::RenameColumn {
table,
old,
new,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column(
conn,
persistence,
source.as_deref(),
&table,
&old,
&new,
));
}
Request::RenameTable {
table,
new,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table(
conn,
persistence,
source.as_deref(),
&table,
&new,
));
}
Request::ChangeColumnType {
table,
column,
ty,
mode,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type(
conn,
persistence,
source.as_deref(),
&table,
&column,
ty,
mode,
));
}
Request::ListTables { reply } => {
let _ = reply.send(do_list_tables(conn));
}
Request::ShowList { kind, name, reply } => {
let _ = reply.send(do_show_list(conn, kind, name.as_deref()));
}
Request::ShowRelationship { name, reply } => {
let _ = reply.send(do_show_relationship(conn, &name));
}
Request::DescribeTable {
name,
source,
reply,
} => {
let _ = reply.send(do_describe_table_request(
conn,
persistence,
source.as_deref(),
&name,
));
}
Request::AddRelationship {
name,
parent_table,
parent_columns,
child_table,
child_columns,
on_delete,
on_update,
create_fk,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship(
conn,
persistence,
source.as_deref(),
name.as_deref(),
&parent_table,
&parent_columns,
&child_table,
&child_columns,
on_delete,
on_update,
create_fk,
));
}
Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_m2n_relationship(
conn,
persistence,
source.as_deref(),
&t1,
&t2,
name.as_deref(),
)
});
}
Request::DropRelationship {
selector,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship(
conn,
persistence,
source.as_deref(),
&selector,
));
}
Request::AddIndex {
name,
table,
columns,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index(
conn,
persistence,
source.as_deref(),
name.as_deref(),
&table,
&columns,
// Simple-mode `add index` is always non-unique
// (ADR-0025); `add unique index` stays deferred. The SQL
// `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d).
false,
));
}
Request::DropIndex {
selector,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index(
conn,
persistence,
source.as_deref(),
&selector,
));
}
Request::SqlDropIndex {
name,
if_exists,
source,
reply,
} => {
// `IF EXISTS` on an absent index is a no-op: reply `Skipped`
// and take **no** snapshot (nothing to undo). The submitted
// line is still journalled (the 4c skip pattern, ADR-0034 /
// ADR-0035 §4). Existence uses the same user-index lookup as
// `do_drop_index` (`sql IS NOT NULL`).
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(DropIndexOutcome::Skipped)
})();
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_index(
conn,
persistence,
source.as_deref(),
&IndexSelector::Named { name: name.clone() },
)
.map(DropIndexOutcome::Dropped)
});
}
}
Request::SqlCreateIndex {
name,
table,
columns,
unique,
if_not_exists,
source,
reply,
} => {
// `IF NOT EXISTS` short-circuits only a *name* collision into
// a no-op (reply `Skipped`, no snapshot, line journalled — the
// 4c skip pattern). The name uses the broad lookup (any index
// of that name), matching `do_add_index`'s collision guard.
// A *different*-named but redundant-column-set create still
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
})();
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_add_index(
conn,
persistence,
source.as_deref(),
name.as_deref(),
&table,
&columns,
unique,
)
.map(CreateIndexOutcome::Created)
});
}
}
Request::AddConstraint {
table,
column,
constraint,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint(
conn,
persistence,
source.as_deref(),
&table,
&column,
&constraint,
));
}
Request::DropConstraint {
table,
column,
kind,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint(
conn,
persistence,
source.as_deref(),
&table,
&column,
kind,
));
}
Request::SetColumnDefault {
table,
column,
default_sql,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_set_column_default(
conn,
persistence,
source.as_deref(),
&table,
&column,
&default_sql,
)
});
}
Request::AlterAddTableCheck {
table,
name,
expr_sql,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_table_check(
conn,
persistence,
source.as_deref(),
&table,
name.as_deref(),
&expr_sql,
)
});
}
Request::AlterAddUnique {
table,
columns,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns)
});
}
Request::AlterDropConstraint {
table,
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name)
});
}
Request::AlterAddForeignKey {
child_table,
name,
fk,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_alter_add_foreign_key(
conn,
persistence,
source.as_deref(),
&child_table,
name.as_deref(),
&fk,
)
});
}
Request::Insert {
table,
columns,
values,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert(
conn,
persistence,
source.as_deref(),
&table,
columns.as_deref(),
&values,
));
}
Request::Update {
table,
assignments,
filter,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update(
conn,
persistence,
source.as_deref(),
&table,
&assignments,
&filter,
));
}
Request::Delete {
table,
filter,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete(
conn,
persistence,
source.as_deref(),
&table,
&filter,
));
}
Request::QueryData {
table,
filter,
limit,
source,
reply,
} => {
let _ = reply.send(do_query_data_request(
conn,
persistence,
source.as_deref(),
&table,
filter.as_ref(),
limit,
));
}
Request::RunSelect { sql, source, reply } => {
let _ = reply.send(do_run_select_request(
conn,
persistence,
source.as_deref(),
&sql,
));
}
Request::RunSqlInsert {
sql,
source,
target_table,
listed_columns,
row_source,
returning,
literal_rows,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
&listed_columns,
&row_source,
returning,
&literal_rows,
));
}
Request::RunSqlUpdate {
sql,
source,
target_table,
returning,
set_literals,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
returning,
&set_literals,
));
}
Request::RunSqlDelete {
sql,
source,
target_table,
returning,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete(
conn,
persistence,
source.as_deref(),
&sql,
&target_table,
returning,
));
}
Request::RebuildFromText {
project_path,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text(
conn,
persistence,
source.as_deref(),
&project_path,
));
}
Request::ExplainPlan { query, reply } => {
let _ = reply.send(do_explain_plan(conn, &query));
}
Request::ReadRelationships { table, reply } => {
let result = do_read_relationships(conn, &table);
let _ = reply.send(result);
}
Request::FindRowsMatching {
table,
column,
value,
limit,
reply,
} => {
let result = do_find_rows_matching(conn, &table, &column, &value, limit);
let _ = reply.send(result);
}
Request::ListNamesFor { source, reply } => {
let result = do_list_names_for(conn, source);
let _ = reply.send(result);
}
// Undo/redo/peek/batch are intercepted in `worker_loop` (they
// need `&mut conn` or persistent batch state) and never reach
// here. Listed explicitly so a new variant still forces a
// decision at compile time.
Request::Undo { .. }
| Request::Redo { .. }
| Request::PeekUndo { .. }
| Request::PeekRedo { .. }
| Request::BeginBatch { .. }
| Request::EndBatch { .. }
| Request::SetMode { .. } => {
unreachable!("undo/redo/peek/batch/set-mode are handled in worker_loop")
}
}
}
/// Schema-name lookup for the completion engine
/// (ADR-0022 §9). Non-schema sources (`NewName`, `Types`, `Free`)
/// never reach here — the public `list_names_for` short-circuits.
fn do_list_names_for(
conn: &Connection,
source: crate::dsl::grammar::IdentSource,
) -> Result<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 mut schema = read_schema_snapshot(conn)?;
// Stamp the live input mode (ADR-0015 mode-restore
// amendment, issue #14). Mode is not stored in the db, so
// `read_schema_snapshot` leaves a placeholder; the
// persister is authoritative and writes the current value.
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)?;
}
for table in &changes.rewritten_tables {
if let Some(snapshot) = read_table_snapshot(conn, table)? {
p.write_table_data(&snapshot)
.map_err(DbError::from_persistence)?;
}
}
for table in &changes.deleted_tables {
p.delete_table_data(table)
.map_err(DbError::from_persistence)?;
}
if let Some(text) = source {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(())
}
/// Read the full user-facing schema (tables, columns,
/// relationships, project metadata) from the database. Reads
/// committed *or* in-tx state because SQLite presents the
/// same connection's writes back through the same connection.
fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
let table_names = do_list_tables(conn)?;
let mut tables: Vec<TableSchema> = Vec::with_capacity(table_names.len());
for name in &table_names {
let read = read_schema(conn, name)?;
let columns: Vec<ColumnSchema> = read
.columns
.iter()
.map(|c| ColumnSchema {
name: c.name.clone(),
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
// user_type is always populated for tables we
// created; the fallback is defensive.
user_type: c.user_type.unwrap_or(Type::Text),
})
.collect();
tables.push(TableSchema {
name: name.clone(),
primary_key: read.primary_key.clone(),
columns,
unique_constraints: read.unique_constraints.clone(),
check_constraints: read.check_constraints.clone(),
});
}
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,
unique: idx.unique,
});
}
}
let created_at = read_project_created_at(conn)?;
Ok(SchemaSnapshot {
created_at,
// Mode is live UI state, not stored in the database
// (ADR-0015 mode-restore amendment, issue #14). This is a
// placeholder the persister overwrites with the current
// mode; the field exists for the read/restore path, where
// `parse_schema` fills it from `project.yaml`.
mode: Mode::default(),
tables,
relationships,
indexes,
})
}
/// Write `project.yaml` now with the current schema and the
/// persister's current input mode (ADR-0015 mode-restore
/// amendment, issue #14). Used by the `SetMode` request so a mode
/// change is saved immediately, without waiting for the next
/// schema-mutating command. A no-op when persistence is absent
/// (in-memory test databases) or the schema is otherwise clean.
fn persist_current_mode(conn: &Connection, p: &Persistence) -> Result<(), DbError> {
let mut schema = read_schema_snapshot(conn)?;
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)
}
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_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
child_table: row.get(3)?,
child_columns: decode_rel_columns(row.get::<_, String>(4)?.as_str()),
on_delete: parse_action_from_sqlite(row.get::<_, String>(5)?.as_str()),
on_update: parse_action_from_sqlite(row.get::<_, String>(6)?.as_str()),
})
})
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
fn read_project_created_at(conn: &Connection) -> Result<String, DbError> {
let value: Option<String> = conn
.query_row(
&format!("SELECT value FROM {META_PROJECT_TABLE} WHERE key = 'created_at'"),
[],
|row| row.get(0),
)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})
.map_err(DbError::from_rusqlite)?;
Ok(value.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()))
}
/// Read a single table's full row data, returning `None` if
/// the table no longer exists (e.g. a recent `drop_table`).
fn read_table_snapshot(
conn: &Connection,
table: &str,
) -> Result<Option<TableSnapshot>, DbError> {
if !user_table_exists(conn, table)? {
return Ok(None);
}
let read = read_schema(conn, table)?;
let columns: Vec<ColumnSchema> = read
.columns
.iter()
.map(|c| ColumnSchema {
name: c.name.clone(),
user_type: c.user_type.unwrap_or(Type::Text),
unique: c.unique,
not_null: c.notnull,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
let column_idents: Vec<String> = read
.columns
.iter()
.map(|c| quote_ident(&c.name))
.collect();
let sql = format!(
"SELECT {} FROM {} ORDER BY rowid",
column_idents.join(", "),
quote_ident(table),
);
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let column_count = read.columns.len();
let mut rows: Vec<Vec<CellValue>> = Vec::new();
let mut iter = stmt.query([]).map_err(DbError::from_rusqlite)?;
while let Some(row) = iter.next().map_err(DbError::from_rusqlite)? {
let mut record: Vec<CellValue> = Vec::with_capacity(column_count);
for i in 0..column_count {
record.push(row_value_to_cell(row, i)?);
}
rows.push(record);
}
Ok(Some(TableSnapshot {
name: table.to_string(),
columns,
rows,
}))
}
fn user_table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_schema \
WHERE type = 'table' AND name = ?1 \
AND substr(name, 1, 8) != '__rdbms_'",
[table],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
Ok(count > 0)
}
/// An engine-neutral "no such table" error for `name`.
fn no_such_table(name: &str) -> DbError {
DbError::Sqlite {
message: format!("no such table: {name}"),
kind: SqliteErrorKind::NoSuchTable,
}
}
/// Resolve a user-supplied table name to its **stored (canonical) case**,
/// or `None` if no such user table exists.
///
/// SQL identifiers are case-insensitive, so a user may type a table name
/// in any capitalization and the engine resolves it. But our metadata
/// tables (keyed by `table_name` / `parent_table` / `child_table`) and the
/// `data/<table>.csv` files are keyed by the *stored* case, and TEXT `=`
/// is case-sensitive — so an executor that used the as-typed name would
/// drift the metadata/CSV out of step with the live schema. Every executor
/// that names a table canonicalizes first and then operates on the
/// canonical name, keeping the live schema, the metadata, and the CSV in
/// step regardless of how the user capitalised the name.
///
/// Internal `__rdbms_*` tables are excluded (treated as non-existent),
/// folding the [`reject_internal_table_name`] guard into the same lookup.
fn canonical_table_name(conn: &Connection, name: &str) -> Result<Option<String>, DbError> {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_schema \
WHERE type = 'table' AND name = ?1 COLLATE NOCASE \
AND name NOT LIKE 'sqlite_%' \
AND substr(name, 1, 8) != '__rdbms_'",
)
.map_err(DbError::from_rusqlite)?;
let mut rows = stmt.query([name]).map_err(DbError::from_rusqlite)?;
match rows.next().map_err(DbError::from_rusqlite)? {
Some(row) => Ok(Some(row.get::<_, String>(0).map_err(DbError::from_rusqlite)?)),
None => Ok(None),
}
}
/// Resolve a table name to its canonical stored case, erroring with a
/// "no such table" if it does not exist (the common executor entry guard).
fn require_canonical_table(conn: &Connection, name: &str) -> Result<String, DbError> {
canonical_table_name(conn, name)?.ok_or_else(|| no_such_table(name))
}
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.
/// Quote + comma-join a column list for a FK clause (ADR-0043):
/// `"a", "b"`. A single-column FK is the one-element case.
fn quote_cols(cols: &[String]) -> String {
cols.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ")
}
fn quote_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for c in name.chars() {
if c == '"' {
out.push_str("\"\"");
} else {
out.push(c);
}
}
out.push('"');
out
}
/// Render a column's constraint-DDL suffix (ADR-0029) — the
/// ` NOT NULL` / ` UNIQUE` / ` DEFAULT <literal>` fragment that
/// follows the column's type in a `CREATE TABLE` or `ALTER
/// TABLE ADD COLUMN`. The default literal is bound against the
/// column's user-facing type, so `default 18` on an `int`
/// column emits `DEFAULT 18` and `default 'x'` on a `text`
/// column emits `DEFAULT 'x'`. (`CHECK` joins this in a later
/// ADR-0029 step.)
fn column_constraints_sql(spec: &ColumnSpec) -> Result<String, DbError> {
let mut sql = String::new();
if spec.not_null {
sql.push_str(" NOT NULL");
}
if spec.unique {
sql.push_str(" UNIQUE");
}
// Advanced-mode raw `DEFAULT <expr>` (ADR-0035 §4a.2) takes
// precedence over a simple-mode typed default; SQLite stores the
// literal text and `PRAGMA table_info` reports it back for the
// round-trip (no metadata needed for DEFAULT).
if let Some(raw) = &spec.default_sql {
sql.push_str(" DEFAULT ");
sql.push_str(raw);
} else if let Some(literal) = default_sql_literal(spec)? {
sql.push_str(" DEFAULT ");
sql.push_str(&literal);
}
Ok(sql)
}
/// The SQL literal for a column's `DEFAULT` value, bound
/// against the column's user-facing type (ADR-0029). `None`
/// when the column carries no default.
fn default_sql_literal(spec: &ColumnSpec) -> Result<Option<String>, DbError> {
match &spec.default {
Some(value) => Ok(Some(value_to_default_sql(value, &spec.name, spec.ty)?)),
None => Ok(None),
}
}
/// The SQL literal for a `DEFAULT` `value` on a `column` of
/// user-facing type `ty` (ADR-0029) — the value-bound,
/// type-checked rendering shared by `default_sql_literal` (the
/// create-table / add-column path) and `do_add_constraint`
/// (the `add constraint default` path).
fn value_to_default_sql(value: &Value, column: &str, ty: Type) -> Result<String, DbError> {
let bound = value
.bind_for_column(column, ty)
.map_err(|e| DbError::InvalidValue(e.to_string()))?;
Ok(sql_literal(&bound_to_sqlite_value(&bound)))
}
/// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 /
/// §7) — the form stored in the `check_expr` metadata column
/// and emitted into column DDL. `compile_expr` produces
/// `?N`-parameterised SQL; `inline_params_for_display`
/// (ADR-0028) folds the literals back in, since DDL admits no
/// parameters.
fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let sql = compile_expr(expr, schema, &mut params);
inline_params_for_display(&sql, &params)
}
/// A minimal `ReadSchema` built from column specs — enough for
/// `compile_expr` to resolve column types when compiling a
/// `CHECK` at create-table time, before the table exists.
fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema {
ReadSchema {
columns: columns
.iter()
.map(|c| ReadColumn {
name: c.name.clone(),
sqlite_type: c.ty.sqlite_strict_type().to_string(),
notnull: c.not_null,
primary_key: primary_key.contains(&c.name),
unique: c.unique,
default_sql: None,
check: None,
user_type: Some(c.ty),
})
.collect(),
primary_key: primary_key.to_vec(),
foreign_keys: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}
}
/// Insert a column's row into the metadata table — the user
/// type, plus the compiled `CHECK` SQL when present
/// (ADR-0029 §7).
fn insert_column_metadata(
conn: &Connection,
table: &str,
column: &str,
user_type: Type,
check_sql: Option<&str>,
) -> Result<(), DbError> {
conn.execute(
&format!(
"INSERT INTO {META_TABLE} \
(table_name, column_name, user_type, check_expr) \
VALUES (?1, ?2, ?3, ?4);"
),
rusqlite::params![table, column, user_type.keyword(), check_sql],
)
.map_err(DbError::from_rusqlite)?;
Ok(())
}
/// The result of an advanced-mode SQL `CREATE TABLE` (ADR-0035 §4).
///
/// Either the table was created, or `IF NOT EXISTS` matched an
/// existing table and the statement was a no-op. Both carry the
/// table's structure so the runtime can render it; `Skipped` also
/// drives the "already exists — skipped" note.
#[derive(Debug)]
pub enum CreateOutcome {
Created(TableDescription),
Skipped(TableDescription),
}
/// The result of an advanced-mode SQL `DROP TABLE` (ADR-0035 §4, 4c).
///
/// Either the table was dropped, or `IF EXISTS` matched no table and
/// the statement was a no-op that drives the "doesn't exist — skipped"
/// note. Carries no payload — the runtime renders the note from the
/// command's table name.
#[derive(Debug)]
pub enum DropOutcome {
Dropped,
Skipped,
}
/// The result of an advanced-mode SQL `DROP INDEX` (ADR-0035 §4d).
///
/// Either the index was dropped — `Dropped` carries the affected
/// table's structure so the runtime auto-shows the now de-indexed table
/// (ADR-0014), unlike `DROP TABLE` whose table is gone — or `IF EXISTS`
/// matched no index and the statement was a no-op driving the "doesn't
/// exist — skipped" note (the index name comes from the command).
#[derive(Debug)]
pub enum DropIndexOutcome {
Dropped(TableDescription),
Skipped,
}
/// The result of an advanced-mode SQL `CREATE [UNIQUE] INDEX`
/// (ADR-0035 §4d).
///
/// Either the index was created — `Created` carries the affected table's
/// structure for the auto-show (ADR-0014) — or `IF NOT EXISTS` matched
/// an existing index name and the statement was a no-op. `Skipped`
/// carries the **resolved** index name (the auto-name is unknown to the
/// command for the unnamed form) to drive the "already exists — skipped"
/// note.
#[derive(Debug)]
pub enum CreateIndexOutcome {
Created(TableDescription),
Skipped(String),
}
#[allow(clippy::too_many_arguments)]
fn do_create_table(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
unique_constraints: &[Vec<String>],
check_constraints: &[String],
foreign_keys: &[SqlForeignKey],
) -> Result<TableDescription, DbError> {
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
// A new table may not take an internal `__rdbms_*` name (it would be
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
// create path rejects this at parse, but the simple-mode DSL
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
// <name>` (ADR-0045) reaches here too — so the shared executor is the
// single place that closes every path (issue raised by the ADR-0045
// /runda pass).
reject_internal_table_name(name)?;
if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps
// the executor honest if anyone synthesises a Command
// directly (tests, future scripting).
return Err(DbError::Unsupported(
"tables need at least one column".to_string(),
));
}
// Resolve + validate any foreign keys before building the DDL, so
// an invalid reference aborts before the table is created (ADR-0035
// §5, sub-phase 4b). Self-references validate against the columns
// being defined; other parents must already exist.
let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?;
if !resolved_fks.is_empty() {
debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated");
}
// Inline `PRIMARY KEY` on the column when the table has a single
// primary-key column and it is the **first** column — the exact
// rule [`schema_to_ddl`] uses on rebuild, so a table's DDL is
// identical whether freshly created or reconstructed (no
// round-trip drift). SQLite grants an inline single-column PK
// rowid-alias semantics; a compound PK, or a single PK that is not
// the first column, gets a table-level constraint. `serial`
// autoincrement does **not** depend on this — the insert path
// computes the next value itself (verified by the multi-column /
// rebuild round-trip tests) — so the choice is purely about
// matching the rebuild generator (ADR-0035 §6.4).
let inline_pk_col: Option<&str> = (primary_key.len() == 1
&& !columns.is_empty()
&& primary_key[0] == columns[0].name)
.then(|| primary_key[0].as_str());
// Compile each column's CHECK once (ADR-0029 §4) — reused
// by the DDL clause and the metadata insert below. The
// minimal schema gives `compile_expr` the column types.
let check_schema = read_schema_for_specs(columns, primary_key);
// Advanced-mode raw `CHECK` text (ADR-0035 §4a.2) wins over a
// compiled simple-mode `Expr`; both are stored verbatim in the
// column metadata and echoed by `schema_to_ddl` on rebuild.
let check_sqls: Vec<Option<String>> = columns
.iter()
.map(|c| {
c.check_sql
.clone()
.or_else(|| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
})
.collect();
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for (col, check_sql) in columns.iter().zip(&check_sqls) {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
sqlite_type = col.ty.sqlite_strict_type(),
);
if inline_pk_col == Some(col.name.as_str()) {
clause.push_str(" PRIMARY KEY");
}
// ADR-0029 column constraints. A single-column PK is
// already NOT NULL + UNIQUE; the simple-mode grammar
// rejects redundant declarations (ADR-0029 §9) and the
// SQL builder de-dups them (ADR-0035 §6.5), so an
// inline-PK column never carries them here.
clause.push_str(&column_constraints_sql(col)?);
if let Some(cs) = check_sql {
clause.push_str(&format!(" CHECK ({cs})"));
}
column_clauses.push(clause);
}
let mut ddl = format!(
"CREATE TABLE {ident} ({columns}",
ident = quote_ident(name),
columns = column_clauses.join(", "),
);
if inline_pk_col.is_none() && !primary_key.is_empty() {
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", PRIMARY KEY (");
ddl.push_str(&pk_idents.join(", "));
ddl.push(')');
}
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
// Single-column UNIQUE rides on the column's inline `UNIQUE`; these
// are the multi-column table-level constraints.
for cols in unique_constraints {
let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", UNIQUE (");
ddl.push_str(&idents.join(", "));
ddl.push(')');
}
// Table-level CHECK constraints (ADR-0035 §4a.3), emitted verbatim
// from the captured raw SQL text. Must stay identical to the
// `schema_to_ddl` rebuild path (the §6.1 two-generators rule).
// The engine has no PRAGMA to report these back, so they are also
// recorded in `CHECK_TABLE` (below) as their source of truth.
for expr in check_constraints {
ddl.push_str(", CHECK (");
ddl.push_str(expr);
ddl.push(')');
}
// Foreign keys (ADR-0035 §5, sub-phase 4b) — emitted identically to
// `schema_to_ddl` (the §6.1 two-generators rule): always the
// explicit resolved parent column + both actions, so the create DDL
// and the rebuild DDL match byte-for-byte.
for fk in &resolved_fks {
ddl.push_str(&format!(
", FOREIGN KEY ({child}) REFERENCES {parent}({pcol}) ON DELETE {od} ON UPDATE {ou}",
child = quote_cols(&fk.child_columns),
parent = quote_ident(&fk.parent_table),
pcol = quote_cols(&fk.parent_columns),
od = fk.on_delete.sql_clause(),
ou = fk.on_update.sql_clause(),
));
}
ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table");
// Wrap the table-creation DDL and the metadata inserts in a
// single transaction so they commit atomically — if either
// step fails, neither side persists.
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
for (col, check_sql) in columns.iter().zip(&check_sqls) {
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
}
// Record table-level CHECKs in their metadata table (the engine
// reports no CHECK constraints, ADR-0035 §4a.3). `seq` preserves
// declaration order so read-back / rebuild re-emit them in order.
for (seq, expr) in check_constraints.iter().enumerate() {
tx.execute(
&format!(
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr) VALUES (?1, ?2, ?3);"
),
rusqlite::params![name, seq as i64, expr],
)
.map_err(DbError::from_rusqlite)?;
}
// Foreign-key relationships (ADR-0035 §5): one metadata row per FK,
// in the same transaction as the table — so the whole statement is
// one undo step.
for fk in &resolved_fks {
insert_relationship_metadata(
&tx,
&fk.name,
&fk.parent_table,
&fk.parent_columns,
name,
&fk.child_columns,
fk.on_delete,
fk.on_update,
)?;
}
let description = do_describe_table(conn, name)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![name.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
fn do_drop_table(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: &str,
) -> Result<(), DbError> {
debug!(table = %name, "drop_table");
// Canonicalize the user-typed name to its stored case (and refuse a
// non-existent / internal table), so the metadata DELETEs and the CSV
// removal target the right name regardless of capitalization.
let canonical_name = require_canonical_table(conn, name)?;
let name = canonical_name.as_str();
// Refuse the drop while any *other* table still has a
// relationship pointing at this one — dropping the parent
// would leave dangling FK constraints in the children. The
// user is told which relationships to drop first.
let inbound = read_relationships_inbound(conn, name)?;
if !inbound.is_empty() {
let names: Vec<&str> = inbound.iter().map(|r| r.name.as_str()).collect();
return Err(DbError::Unsupported(format!(
"cannot drop `{name}`: it is referenced by relationship(s) [{}]. \
Drop those relationships first.",
names.join(", ")
)));
}
let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
debug!(ddl = %ddl, "drop_table");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
// Outbound relationships are gone with the table — clean
// their metadata too.
tx.execute(
&format!("DELETE FROM {REL_TABLE} WHERE child_table = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
// Table-level CHECK metadata goes with the table (ADR-0035 §4a.3).
tx.execute(
&format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: Vec::new(),
deleted_tables: vec![name.to_string()],
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
}
/// Add a column to an existing table.
///
/// Two execution paths per ADR-0018 §6:
///
/// - **Plain types** (`text`, `int`, `real`, `decimal`, `bool`,
/// `date`, `datetime`): `ALTER TABLE ADD COLUMN`. Existing
/// rows get NULL in the new column — that's the SQL standard
/// behaviour and acceptable for non-auto-generated types.
/// - **Auto-generated types** (`serial`, `shortid`): route
/// through the rebuild-table primitive so we can populate
/// the new column atomically with table creation. Existing
/// rows receive a generated value (1..N for serial, fresh
/// shortids for shortid) and the column gains UNIQUE +
/// NOT NULL.
///
/// `blob` is statically refused as a column-add target by the
/// existing infrastructure (no DSL literal, downstream INSERT
/// would fail), but the path itself is allowed via the plain
/// branch — same as today.
fn do_add_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %column.name, "add_column");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
if matches!(column.ty, Type::Serial | Type::ShortId) {
// ADR-0029 §6: a `serial` / `shortid` column auto-fills
// its own values, so a separate `default` is ambiguous.
// (`default_sql` is the advanced-mode raw form — ADR-0035 §4e.)
if column.default.is_some() || column.default_sql.is_some() {
return Err(DbError::Unsupported(format!(
"`{name}` is a {ty} column — it auto-fills its own values, \
so it cannot also carry a `default`.",
name = column.name,
ty = column.ty.keyword(),
)));
}
// A CHECK on an auto-generated column is supported at
// `create table` time; adding one to a `serial` /
// `shortid` column afterwards is not (the auto-fill
// rebuild path does not thread it). `check_sql` is the
// advanced-mode raw form.
if column.check.is_some() || column.check_sql.is_some() {
return Err(DbError::Unsupported(format!(
"a `check` constraint on the auto-generated column `{}` \
can only be set when the table is created.",
column.name,
)));
}
return do_add_auto_generated_column(conn, persistence, source, table, column);
}
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
// or `CHECK`, and a `NOT NULL` column added that way must
// carry a default — all route through the rebuild primitive
// instead (ADR-0029 §6). The advanced-mode raw forms
// (`check_sql` / `default_sql`, ADR-0035 §4e) count alongside
// the typed AST forms.
if column.unique
|| column.check.is_some()
|| column.check_sql.is_some()
|| (column.not_null && column.default.is_none() && column.default_sql.is_none())
{
do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
} else {
do_add_plain_column(conn, persistence, source, table, column)
}
}
/// Plain ALTER-TABLE path for non-auto-generated types.
fn do_add_plain_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_plain_column");
// The plain `ALTER TABLE ADD COLUMN` path. `do_add_column`
// only routes here when the constraints are ALTER-expressible
// (no UNIQUE; NOT NULL only alongside a default), so the
// ADR-0029 suffix appends cleanly.
let ty = spec.ty;
let column = spec.name.as_str();
let ddl = format!(
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{constraints};",
tbl = quote_ident(table),
col = quote_ident(column),
sqlite_type = ty.sqlite_strict_type(),
constraints = column_constraints_sql(spec)?,
);
debug!(ddl = %ddl, "add_column");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, column, ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(AddColumnResult {
description,
client_side_notes: Vec::new(),
})
}
/// Auto-fill path for `serial` / `shortid` (ADR-0018 §6 + §9).
///
/// Empty table: the new column is added through the rebuild
/// primitive too, but no auto-fill / `[client-side]` note is
/// produced — there are no rows to populate.
fn do_add_auto_generated_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_auto_generated_column");
use rusqlite::types::Value as RV;
let ty = spec.ty;
let column = spec.name.as_str();
let old_schema = read_schema(conn, table)?;
if old_schema.columns.iter().any(|c| c.name == column) {
return Err(DbError::Unsupported(format!(
"column `{table}.{column}` already exists."
)));
}
let row_count = count_rows(conn, table)? as usize;
// Build the new schema with the auto-generated column
// appended. UNIQUE + NOT NULL emit per ADR-0018 §4.
let mut new_schema = old_schema.clone();
new_schema.columns.push(ReadColumn {
name: column.to_string(),
sqlite_type: ty.sqlite_strict_type().to_string(),
notnull: true,
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(ty),
});
// Generate auto-fill values for every existing row.
let auto_fill_values: Vec<RV> = match ty {
Type::Serial => (1..=row_count as i64).map(RV::Integer).collect(),
Type::ShortId => generate_shortid_batch(row_count, &[])?,
_ => unreachable!("guarded by `auto_generated` flag"),
};
let old_columns: Vec<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
let new_columns: Vec<String> = new_schema.columns.iter().map(|c| c.name.clone()).collect();
let copy_data = |tx: &rusqlite::Transaction<'_>,
temp_name: &str,
orig: &str|
-> Result<(), DbError> {
if row_count == 0 {
return Ok(());
}
// Read all rows from old, append the auto-fill value,
// INSERT into temp.
let select_cols = old_columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let select_sql = format!(
"SELECT {select_cols} FROM {orig};",
orig = quote_ident(orig),
);
let cols_csv = new_columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let placeholders = (1..=new_columns.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let insert_sql = format!(
"INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});",
temp = quote_ident(temp_name),
);
let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?;
let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?;
let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?;
let mut row_idx = 0usize;
while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
let mut values: Vec<RV> = Vec::with_capacity(new_columns.len());
for i in 0..old_columns.len() {
values.push(r.get(i).map_err(DbError::from_rusqlite)?);
}
values.push(auto_fill_values[row_idx].clone());
let params: Vec<&dyn rusqlite::ToSql> =
values.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
insert_stmt
.execute(rusqlite::params_from_iter(params))
.map_err(DbError::from_rusqlite)?;
row_idx += 1;
}
Ok(())
};
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, column, ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
rebuild_table_with_copy(conn, table, &new_schema, copy_data, metadata_updates)?;
let description = do_describe_table(conn, table)?;
let mut client_side_notes = Vec::new();
if row_count > 0 {
client_side_notes.push(format_auto_fill_add_note(ty, row_count));
}
Ok(AddColumnResult {
description,
client_side_notes,
})
}
/// Add a plain column whose constraints `ALTER TABLE ADD
/// COLUMN` cannot express — `UNIQUE`, or `NOT NULL` with no
/// default — through the rebuild primitive (ADR-0029 §6). A
/// pre-flight check refuses, with a friendly message, the
/// cases the table's existing rows would violate.
fn do_add_constrained_column_via_rebuild(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild");
let old_schema = read_schema(conn, table)?;
if old_schema.columns.iter().any(|c| c.name == spec.name) {
return Err(DbError::Unsupported(format!(
"column `{table}.{}` already exists.",
spec.name,
)));
}
let row_count = count_rows(conn, table)?;
// ADR-0029 §6 pre-flight refusals — caught before any SQL
// write, surfaced as friendly messages.
// A default may come from the typed AST (`default`, simple mode) or
// the raw advanced-mode text (`default_sql`, ADR-0035 §4e); either
// satisfies the NOT-NULL backfill and triggers the UNIQUE collision
// guard.
let has_default = spec.default.is_some() || spec.default_sql.is_some();
if spec.not_null && !has_default && row_count > 0 {
return Err(DbError::Unsupported(format!(
"adding the NOT NULL column `{}` to `{table}`, which already \
has {row_count} row(s), needs a `default` — every existing \
row would otherwise be null.",
spec.name,
)));
}
if spec.unique && has_default && row_count > 1 {
return Err(DbError::Unsupported(format!(
"adding the UNIQUE column `{}` with a default to `{table}` \
would give all {row_count} existing rows the same value, \
breaking uniqueness.",
spec.name,
)));
}
// The DEFAULT literal: the raw advanced-mode text wins over the typed
// form (ADR-0035 §4e, mirroring `column_constraints_sql`).
let default_sql = match &spec.default_sql {
Some(raw) => Some(raw.clone()),
None => default_sql_literal(spec)?,
};
// Append the new column to the schema; the rebuild's
// column-by-name copy leaves it at its DEFAULT (or NULL).
let mut new_schema = old_schema.clone();
new_schema.columns.push(ReadColumn {
name: spec.name.clone(),
sqlite_type: spec.ty.sqlite_strict_type().to_string(),
notnull: spec.not_null,
primary_key: false,
unique: spec.unique,
default_sql,
check: None,
user_type: Some(spec.ty),
});
// The CHECK: the raw advanced-mode text (`check_sql`) wins; otherwise
// the typed `check` AST is compiled against the post-add schema (so
// it may reference the new column itself).
let check_sql = spec
.check_sql
.clone()
.or_else(|| spec.check.as_ref().map(|e| compile_check_sql(e, &new_schema)));
if let Some(last) = new_schema.columns.last_mut() {
last.check.clone_from(&check_sql);
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
Ok(AddColumnResult {
description: do_describe_table(conn, table)?,
client_side_notes: Vec::new(),
})
}
// =================================================================
// add constraint / drop constraint (ADR-0029 §2.2 / §5 / §9)
// =================================================================
/// Add a column-level constraint to an existing column
/// (ADR-0029 §2.2). Steps: resolve the column; apply the §9
/// redundant-on-PK and §6 default-on-auto-generated refusals;
/// run the §5 dry-run for the constraints existing data can
/// violate (`not null` / `unique` / `check`); then apply the
/// constraint through the rebuild-table primitive — SQLite's
/// `ALTER TABLE` cannot add these to an existing column.
fn do_add_constraint(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
constraint: &Constraint,
) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "add_constraint");
// Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling
// schema-mutation executors. Closes the simple `add constraint`
// exposure and the SQL `ALTER TABLE … ADD CONSTRAINT` decomposition
// target (ADR-0035 §4g).
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let (col_is_pk, col_user_type) = {
let col = old_schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table}.{column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
(col.primary_key, col.user_type)
};
let single_column_pk = old_schema.primary_key.len() == 1;
// ADR-0029 §9 — a constraint the primary key already
// implies is a friendly refusal, not a silent no-op.
match constraint {
Constraint::NotNull if col_is_pk => {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a primary-key column, so it is already \
NOT NULL — there is no constraint to add."
)));
}
Constraint::Unique if col_is_pk && single_column_pk => {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a single-column primary key, so it is \
already UNIQUE — there is no constraint to add."
)));
}
// ADR-0029 §6 — an auto-generated column fills its own
// values, so a `default` would be a second, ambiguous
// source of "the value when none is given".
Constraint::Default(_)
if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) =>
{
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a {ty} column — it auto-fills its own \
values, so it cannot also carry a `default`.",
ty = col_user_type.expect("matched Some above").keyword(),
)));
}
_ => {}
}
// Compile the CHECK once — reused by the dry-run predicate
// and the column DDL / metadata write.
let check_sql: Option<String> = match constraint {
Constraint::Check(expr) => Some(compile_check_sql(expr, &old_schema)),
_ => None,
};
// ADR-0029 §5 — refuse, before any SQL write, when the
// existing rows violate the constraint. `default` never
// touches existing rows, so it skips the dry-run.
let refusal = match constraint {
Constraint::NotNull => dry_run_not_null(conn, &old_schema, table, column)?,
Constraint::Unique => dry_run_unique(conn, &old_schema, table, column)?,
Constraint::Check(_) => dry_run_check(
conn,
&old_schema,
table,
column,
check_sql.as_deref().expect("check_sql set for a Check constraint"),
)?,
Constraint::Default(_) => None,
};
if let Some(message) = refusal {
return Err(DbError::Unsupported(message));
}
// Build the post-add schema: the target column gains the
// constraint; every other column carries over unchanged.
let mut new_schema = old_schema.clone();
{
let target = new_schema
.columns
.iter_mut()
.find(|c| c.name == column)
.expect("column existence checked above");
match constraint {
Constraint::NotNull => target.notnull = true,
Constraint::Unique => target.unique = true,
Constraint::Default(value) => {
let ty = target.user_type.ok_or_else(|| {
DbError::Unsupported(format!(
"`{table}.{column}` has no user-type metadata; \
cannot bind a default value."
))
})?;
target.default_sql = Some(value_to_default_sql(value, column, ty)?);
}
Constraint::Check(_) => target.check.clone_from(&check_sql),
}
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
// Only CHECK needs a metadata write — NOT NULL / UNIQUE /
// DEFAULT are recoverable from the engine's own catalog
// (ADR-0029 §7).
if matches!(constraint, Constraint::Check(_)) {
tx.execute(
&format!(
"UPDATE {META_TABLE} SET check_expr = ?1 \
WHERE table_name = ?2 AND column_name = ?3;"
),
rusqlite::params![check_sql, table, column],
)
.map_err(DbError::from_rusqlite)?;
}
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
do_describe_table(conn, table)
}
/// Remove a column-level constraint from an existing column
/// (ADR-0029 §2.2). Removing a constraint cannot violate the
/// data, so there is no dry-run; the §9 PK-implied refusals
/// still apply, and dropping a constraint the column does not
/// carry is itself a friendly refusal.
fn do_drop_constraint(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
kind: ConstraintKind,
) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "drop_constraint");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let (col_is_pk, present) = {
let col = old_schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table}.{column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let present = match kind {
ConstraintKind::NotNull => col.notnull,
ConstraintKind::Unique => col.unique,
ConstraintKind::Default => col.default_sql.is_some(),
ConstraintKind::Check => col.check.is_some(),
};
(col.primary_key, present)
};
let single_column_pk = old_schema.primary_key.len() == 1;
// ADR-0029 §9 — the primary key still enforces these, so
// there is nothing for `drop constraint` to remove.
match kind {
ConstraintKind::NotNull if col_is_pk => {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a primary-key column — its NOT NULL is \
enforced by the primary key and cannot be dropped."
)));
}
ConstraintKind::Unique if col_is_pk && single_column_pk => {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a single-column primary key — its \
UNIQUE is enforced by the primary key and cannot be dropped."
)));
}
_ => {}
}
if !present {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` has no {kind} constraint to drop.",
kind = kind.label(),
)));
}
let mut new_schema = old_schema.clone();
{
let target = new_schema
.columns
.iter_mut()
.find(|c| c.name == column)
.expect("column existence checked above");
match kind {
ConstraintKind::NotNull => target.notnull = false,
ConstraintKind::Unique => target.unique = false,
ConstraintKind::Default => target.default_sql = None,
ConstraintKind::Check => target.check = None,
}
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
if kind == ConstraintKind::Check {
tx.execute(
&format!(
"UPDATE {META_TABLE} SET check_expr = NULL \
WHERE table_name = ?1 AND column_name = ?2;"
),
rusqlite::params![table, column],
)
.map_err(DbError::from_rusqlite)?;
}
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
do_describe_table(conn, table)
}
/// `ALTER TABLE … ALTER COLUMN <col> SET DEFAULT <expr>` — set the
/// column's default to raw SQL text (ADR-0035 Amendment 2). Mirrors
/// `do_add_constraint`'s `Default` branch (the §6 `serial`/`shortid`
/// refusal; no dry-run — a default never touches existing rows) but
/// takes raw `sql_expr` text instead of a typed `Value` (advanced
/// `SET DEFAULT` has no AST). DEFAULT is recoverable from the engine
/// catalog, so there is no metadata write — the rebuilt table's DDL
/// carries it (ADR-0029 §7).
fn do_set_column_default(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
default_sql: &str,
) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "set_column_default");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let col_user_type = {
let col = old_schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table}.{column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
col.user_type
};
// ADR-0029 §6 — an auto-generated column fills its own values, so a
// `default` would be a second, ambiguous source of "the value when
// none is given" (mirrors do_add_constraint).
if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is a {ty} column — it auto-fills its own \
values, so it cannot also carry a `default`.",
ty = col_user_type.expect("matched Some above").keyword(),
)));
}
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.default_sql = Some(default_sql.to_string());
}
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
do_describe_table(conn, table)
}
/// A row's primary-key cell values paired with its value in
/// the column under test — the unit an ADR-0029 §5 dry-run
/// scans.
type DryRunRow = (Vec<rusqlite::types::Value>, rusqlite::types::Value);
/// Read the primary-key cell values of every row of `table`
/// matching `where_sql`, paired with the row's value in
/// `column`. The shared read behind the three ADR-0029 §5
/// dry-runs; `where_sql` is built from `quote_ident`-quoted
/// names and `compile_check_sql` output, never raw user text.
fn read_constraint_dry_run_rows(
conn: &Connection,
schema: &ReadSchema,
table: &str,
column: &str,
where_sql: &str,
) -> Result<Vec<DryRunRow>, DbError> {
use rusqlite::types::Value as RV;
let pk_columns = &schema.primary_key;
let mut select_idents: Vec<String> =
pk_columns.iter().map(|c| quote_ident(c)).collect();
select_idents.push(quote_ident(column));
let sql = format!(
"SELECT {cols} FROM {tbl} WHERE {pred};",
cols = select_idents.join(", "),
tbl = quote_ident(table),
pred = where_sql,
);
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let pk_count = pk_columns.len();
let mut out = Vec::new();
let mut rows = stmt.query([]).map_err(DbError::from_rusqlite)?;
while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
let mut pk_values: Vec<RV> = Vec::with_capacity(pk_count);
for i in 0..pk_count {
pk_values.push(r.get(i).map_err(DbError::from_rusqlite)?);
}
let target: RV = r.get(pk_count).map_err(DbError::from_rusqlite)?;
out.push((pk_values, target));
}
Ok(out)
}
/// Header cells identifying an offending row in a §5 dry-run
/// table — the primary-key columns. The DSL always creates a
/// primary key, so the no-PK fallback (the constrained column
/// itself) is defensive only.
fn dry_run_id_headers(schema: &ReadSchema, column: &str) -> Vec<String> {
if schema.primary_key.is_empty() {
vec![column.to_string()]
} else {
pk_header_cells(&schema.primary_key)
}
}
fn dry_run_id_alignments(schema: &ReadSchema) -> Vec<Alignment> {
if schema.primary_key.is_empty() {
vec![Alignment::Left]
} else {
pk_header_alignments(&schema.primary_key, schema)
}
}
fn dry_run_id_cells(
schema: &ReadSchema,
pk_values: &[rusqlite::types::Value],
target: &rusqlite::types::Value,
) -> Vec<String> {
if schema.primary_key.is_empty() {
vec![render_value(target)]
} else {
pk_value_cells(pk_values)
}
}
/// Assemble a §5 dry-run refusal: a summary line above the
/// pretty-printed table of offending rows.
fn render_constraint_dry_run(
summary: String,
headers: &[String],
alignments: &[Alignment],
rows: Vec<Vec<String>>,
) -> String {
let mut out = format!("{summary}\n\n");
for line in render_diagnostic_table(headers, &rows, alignments) {
out.push_str(&line);
out.push('\n');
}
out
}
/// ADR-0029 §5 dry-run for `add constraint not null`: any row
/// whose target column holds `NULL` violates the constraint.
fn dry_run_not_null(
conn: &Connection,
schema: &ReadSchema,
table: &str,
column: &str,
) -> Result<Option<String>, DbError> {
let rows = read_constraint_dry_run_rows(
conn,
schema,
table,
column,
&format!("{col} IS NULL", col = quote_ident(column)),
)?;
if rows.is_empty() {
return Ok(None);
}
let total = rows.len();
let headers = dry_run_id_headers(schema, column);
let alignments = dry_run_id_alignments(schema);
let visible = total.min(DIAGNOSTIC_ROW_CAP);
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
for (pk, target) in rows.iter().take(visible) {
out_rows.push(dry_run_id_cells(schema, pk, target));
}
if total > visible {
out_rows.push(more_row(headers.len(), total - visible));
}
Ok(Some(render_constraint_dry_run(
crate::t!(
"db.diagnostic.add_not_null_summary",
table = table,
column = column,
total = total,
),
&headers,
&alignments,
out_rows,
)))
}
/// ADR-0029 §5 dry-run for `add constraint unique`: any
/// non-`NULL` value shared by two or more rows collides
/// (SQL's "NULLs are distinct" rule means `NULL`s never do).
fn dry_run_unique(
conn: &Connection,
schema: &ReadSchema,
table: &str,
column: &str,
) -> Result<Option<String>, DbError> {
use std::collections::BTreeMap;
let rows = read_constraint_dry_run_rows(
conn,
schema,
table,
column,
&format!("{col} IS NOT NULL", col = quote_ident(column)),
)?;
let mut groups: BTreeMap<String, Vec<DryRunRow>> = BTreeMap::new();
for (pk, target) in rows {
groups
.entry(render_value(&target))
.or_default()
.push((pk, target));
}
let collisions: Vec<(String, Vec<DryRunRow>)> = groups
.into_iter()
.filter(|(_, members)| members.len() >= 2)
.collect();
if collisions.is_empty() {
return Ok(None);
}
let total = collisions.len();
let pk_label = if schema.primary_key.is_empty() {
column.to_string()
} else {
schema.primary_key.join(", ")
};
let headers = vec![
crate::t!("db.diagnostic.header_value"),
crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
];
let alignments = vec![Alignment::Left, Alignment::Left];
let visible = total.min(DIAGNOSTIC_ROW_CAP);
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
for (value, members) in collisions.iter().take(visible) {
let ids = members
.iter()
.take(5)
.map(|(pk, target)| {
if schema.primary_key.is_empty() {
render_value(target)
} else {
pk_value_cells_inline(pk)
}
})
.collect::<Vec<_>>()
.join(", ");
let ids = if members.len() > 5 {
format!("{ids}, …")
} else {
ids
};
out_rows.push(vec![value.clone(), ids]);
}
if total > visible {
out_rows.push(more_row(headers.len(), total - visible));
}
Ok(Some(render_constraint_dry_run(
crate::t!(
"db.diagnostic.add_unique_summary",
table = table,
column = column,
total = total,
),
&headers,
&alignments,
out_rows,
)))
}
/// ADR-0029 §5 dry-run for `add constraint check`: any row for
/// which the compiled `check_sql` is definitively false
/// violates the constraint. (`NOT (expr)` is true only when
/// `expr` is false — a `NULL` result, which the engine's CHECK
/// also tolerates, does not select the row.)
fn dry_run_check(
conn: &Connection,
schema: &ReadSchema,
table: &str,
column: &str,
check_sql: &str,
) -> Result<Option<String>, DbError> {
let rows = read_constraint_dry_run_rows(
conn,
schema,
table,
column,
&format!("NOT ({check_sql})"),
)?;
if rows.is_empty() {
return Ok(None);
}
let total = rows.len();
let mut headers = dry_run_id_headers(schema, column);
headers.push(crate::t!("db.diagnostic.header_value"));
let mut alignments = dry_run_id_alignments(schema);
alignments.push(Alignment::Left);
let visible = total.min(DIAGNOSTIC_ROW_CAP);
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
for (pk, target) in rows.iter().take(visible) {
let mut cells = dry_run_id_cells(schema, pk, target);
cells.push(render_value(target));
out_rows.push(cells);
}
if total > visible {
out_rows.push(more_row(headers.len(), total - visible));
}
Ok(Some(render_constraint_dry_run(
crate::t!(
"db.diagnostic.add_check_summary",
table = table,
column = column,
total = total,
rule = check_sql,
),
&headers,
&alignments,
out_rows,
)))
}
/// Generate `count` shortid values that don't collide with each
/// other or with `existing` (a slice of currently-stored
/// shortid values, used during change-column-to-shortid). Up to
/// 5 retries per cell per ADR-0018 §3.
fn generate_shortid_batch(
count: usize,
existing: &[String],
) -> Result<Vec<rusqlite::types::Value>, DbError> {
use std::collections::HashSet;
let mut taken: HashSet<String> = existing.iter().cloned().collect();
let mut out: Vec<rusqlite::types::Value> = Vec::with_capacity(count);
for _ in 0..count {
let mut generated: Option<String> = None;
for _ in 0..5 {
let candidate = shortid::generate();
if !taken.contains(&candidate) {
taken.insert(candidate.clone());
generated = Some(candidate);
break;
}
}
match generated {
Some(v) => out.push(rusqlite::types::Value::Text(v)),
None => {
return Err(DbError::Unsupported(
"could not generate a unique shortid after 5 attempts; \
this typically indicates a generator-state issue, not \
a recoverable user error."
.to_string(),
));
}
}
}
Ok(out)
}
/// `[client-side]` note line for `add column T: x (serial|shortid)`
/// on a non-empty table per ADR-0018 §9.
fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String {
match ty {
Type::Serial => {
crate::t!("client_side.auto_fill_add_serial", count = row_count)
}
Type::ShortId => {
crate::t!("client_side.auto_fill_add_shortid", count = row_count)
}
_ => unreachable!("called only for serial/shortid"),
}
}
/// Drop a column from a table.
///
/// Uses SQLite's native `ALTER TABLE … DROP COLUMN`
/// (available since SQLite 3.35) so we get the engine's
/// constraint checks for free; SQLite refuses if the column
/// is part of the PK, has a UNIQUE constraint, is referenced
/// in a CHECK, or is used in an FK. In addition we run two
/// up-front checks so the user gets friendly messages
/// before SQLite refuses:
///
/// - Refuse PK columns (the dominant case the user might
/// try).
/// - Refuse columns involved in a declared relationship
/// (per `__rdbms_playground_relationships`). Drop the
/// relationship first.
fn do_drop_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
cascade: bool,
) -> Result<DropColumnResult, DbError> {
debug!(table = %table, column = %column, cascade, "drop_column");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
let col_info = schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table}.{column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
if col_info.primary_key {
return Err(DbError::Unsupported(format!(
"cannot drop primary-key column `{table}.{column}`. \
Drop the table or change the primary key first."
)));
}
let rel_count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {REL_TABLE} \
WHERE (parent_table = ?1 AND parent_column = ?2) \
OR (child_table = ?1 AND child_column = ?2);"
),
rusqlite::params![table, column],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if rel_count > 0 {
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` while a relationship \
references it; drop the relationship first."
)));
}
// Indexes covering this column (ADR-0025). Without
// `--cascade` a covered column is refused; with it, the
// covering indexes are dropped alongside the column.
let covering: Vec<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."
)));
}
// A composite UNIQUE covering this column (ADR-0035 Amendment 1): the
// engine refuses to drop a column a UNIQUE constraint spans, so refuse
// up-front with the constraint's derived name and the actionable drop
// command. Single-column UNIQUEs ride on the column `unique` flag (the
// engine drops their auto-index with the column), not
// `unique_constraints`, so they do not reach here.
if let Some(cols) = schema
.unique_constraints
.iter()
.find(|cols| cols.iter().any(|c| c == column))
{
let cname = unique_constraint_name(cols);
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` — it is part of the UNIQUE \
constraint `{cname}` ({}); drop that constraint first \
(`alter table {table} drop constraint {cname}`).",
cols.join(", "),
)));
}
// A single-column UNIQUE on this column (ADR-0029): the engine refuses
// to drop a column carrying a UNIQUE constraint. Unlike a composite
// UNIQUE (handled above), a single-column UNIQUE is removed by the
// column-level `drop constraint` — point there (ADR-0035 Amendment 1,
// gap B).
if col_info.unique {
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` — it has a UNIQUE constraint; \
remove the constraint first (`drop constraint unique from \
{table}.{column}`), then drop the column."
)));
}
// A CHECK (table-level, or a *different* column's column-level CHECK)
// that references this column (ADR-0035 §4e, the 4a.3 deferral): a
// deliberate up-front refusal — dropping the column would break that
// CHECK and the rebuilt DDL would name a missing column. The column's
// own column-level CHECK drops with it, so it does not block.
// Friendly wording is H1. Guards both surfaces.
if column_referenced_by_check(conn, table, &schema, column, false)? {
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` — a CHECK constraint refers to \
it, and dropping the column would leave that rule pointing at \
a column that no longer exists. Drop or change the CHECK \
constraint first, then drop the column."
)));
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// Drop covering indexes first — the engine refuses
// DROP COLUMN on an indexed column otherwise. `covering`
// is empty unless `--cascade` was given (the refusal above).
for index in &covering {
tx.execute_batch(&format!(
"DROP INDEX {ident};",
ident = quote_ident(&index.name)
))
.map_err(DbError::from_rusqlite)?;
}
let ddl = format!(
"ALTER TABLE {tbl} DROP COLUMN {col};",
tbl = quote_ident(table),
col = quote_ident(column),
);
debug!(ddl = %ddl, "drop_column");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;"
),
[table, column],
)
.map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DropColumnResult {
description,
dropped_indexes: covering.into_iter().map(|i| i.name).collect(),
})
}
/// Rename a column.
///
/// Uses SQLite's native `ALTER TABLE … RENAME COLUMN`
/// (available since SQLite 3.25), which automatically
/// updates references in views, triggers, and FK
/// declarations on other tables. We mirror the rename into
/// our two metadata tables so they don't drift.
fn do_rename_column(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
old: &str,
new: &str,
) -> Result<TableDescription, DbError> {
debug!(table = %table, old = %old, new = %new, "rename_column");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
if !schema.columns.iter().any(|c| c.name == old) {
return Err(DbError::Sqlite {
message: format!("no such column: {table}.{old}"),
kind: SqliteErrorKind::NoSuchColumn,
});
}
// A CHECK that references this column (ADR-0035 §4e): refuse — a
// native RENAME COLUMN rewrites the live CHECK but the stored CHECK
// text (table-level in `__rdbms_playground_table_checks`, or
// column-level in `__rdbms_playground_columns`) keeps the old name,
// drifting the metadata and breaking a later rebuild. A column's
// *own* column-level CHECK drifts too (`include_self = true`).
// Deliberate refusal (friendly wording is H1); guards both surfaces.
if column_referenced_by_check(conn, table, &schema, old, true)? {
return Err(DbError::Unsupported(format!(
"cannot rename `{table}.{old}` — a CHECK constraint refers to \
it by name, and the rename would leave that rule pointing at \
the old name. Drop or change the CHECK constraint first, then \
rename the column."
)));
}
if old == new {
// Nothing to do; refusing keeps behaviour
// predictable rather than appearing to "succeed"
// with no effect.
return Err(DbError::Unsupported(format!(
"rename: new name is identical to the existing one (`{old}`)."
)));
}
if schema.columns.iter().any(|c| c.name == new) {
return Err(DbError::Unsupported(format!(
"column `{table}.{new}` already exists; pick a different name."
)));
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let ddl = format!(
"ALTER TABLE {tbl} RENAME COLUMN {old_c} TO {new_c};",
tbl = quote_ident(table),
old_c = quote_ident(old),
new_c = quote_ident(new),
);
debug!(ddl = %ddl, "rename_column");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
// Mirror the rename into __rdbms_playground_columns.
tx.execute(
&format!(
"UPDATE {META_TABLE} SET column_name = ?1 \
WHERE table_name = ?2 AND column_name = ?3;"
),
[new, table, old],
)
.map_err(DbError::from_rusqlite)?;
// Mirror into __rdbms_playground_relationships on
// BOTH sides — a column may be the parent endpoint or
// the child endpoint (or, for self-referencing tables,
// both).
tx.execute(
&format!(
"UPDATE {REL_TABLE} SET parent_column = ?1 \
WHERE parent_table = ?2 AND parent_column = ?3;"
),
[new, table, old],
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"UPDATE {REL_TABLE} SET child_column = ?1 \
WHERE child_table = ?2 AND child_column = ?3;"
),
[new, table, old],
)
.map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Rename a table (ADR-0035 §6, sub-phase 4h) — the one genuinely new
/// low-level op in Phase 4.
///
/// Uses SQLite's native `ALTER TABLE … RENAME TO` (structure-preserving,
/// so no rebuild), which also rewrites references to the old name in
/// other tables' FK declarations and inside the renamed table's own
/// CHECK / self-FK definitions (the engine's modern, non-legacy
/// behaviour). We then reconcile everything the engine does *not* know
/// about, all in one transaction (commit-db-last, ADR-0015 §6):
///
/// 1. every metadata row that names the table — `__rdbms_playground_columns`
/// (`table_name`), **both ends** of `__rdbms_playground_relationships`
/// (`parent_table` *and* `child_table`, covering a self-referential
/// table), and `__rdbms_playground_table_checks` (`table_name`);
/// 2. CHECK *text* that qualifies a column with the old table name
/// (`T.age` → `U.age`), in both metadata tables — the live schema was
/// already rewritten, so the stored text must match or a rebuild fails;
/// 3. the CSV file, via the existing persistence rewrite+delete path
/// (`rewritten_tables = [new]`, `deleted_tables = [old]`).
///
/// Auto-named indexes and relationships keep their (now stale but
/// functional) names — only the table-name *columns* update (ADR-0035 §6
/// scope; user-confirmed). One undo step (the worker's whole-project
/// snapshot).
fn do_rename_table(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
old: &str,
new: &str,
) -> Result<TableDescription, DbError> {
debug!(old = %old, new = %new, "rename_table");
reject_internal_table_name(new)?;
// Canonicalize the source to its stored case (and refuse a
// non-existent / internal source as "no such table") — so a
// case-variant source name still resolves to the real table and the
// metadata UPDATEs below match the stored case.
let canonical_old = require_canonical_table(conn, old)?;
let old = canonical_old.as_str();
if old == new {
return Err(DbError::Unsupported(format!(
"rename: new name is identical to the existing one (`{old}`)."
)));
}
// The database matches table names case-insensitively, so collision
// checks must be case-insensitive too — otherwise the native rename
// surfaces a raw engine collision error (ADR-0035 §9). A target that
// differs from the source only in capitalization is a no-op the engine
// cannot perform; a target colliding with a *different* table is the
// ordinary "already exists" refusal.
if old.eq_ignore_ascii_case(new) {
return Err(DbError::Unsupported(format!(
"`{old}` and `{new}` differ only in capitalization; the database \
treats them as the same table, so there is nothing to rename."
)));
}
let tables = do_list_tables(conn)?;
if tables.iter().any(|t| t.eq_ignore_ascii_case(new)) {
return Err(DbError::Unsupported(format!(
"table `{new}` already exists; pick a different name."
)));
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let ddl = format!(
"ALTER TABLE {old_t} RENAME TO {new_t};",
old_t = quote_ident(old),
new_t = quote_ident(new),
);
debug!(ddl = %ddl, "rename_table");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
// (1) Rename the table name in every metadata row that names it.
tx.execute(
&format!("UPDATE {META_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
// Both relationship ends — parent and child — so an FK parent, an FK
// child, and a self-referencing table are all covered. The
// relationship `name` is left as-is (auto-names embed the old table
// name; stale-but-functional per the user decision, like index names).
tx.execute(
&format!("UPDATE {REL_TABLE} SET parent_table = ?1 WHERE parent_table = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("UPDATE {REL_TABLE} SET child_table = ?1 WHERE child_table = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("UPDATE {CHECK_TABLE} SET table_name = ?1 WHERE table_name = ?2;"),
[new, old],
)
.map_err(DbError::from_rusqlite)?;
// (2) Reconcile CHECK *text* that qualifies a reference with the old
// table name. Read the rows (now keyed by `new`), rewrite, write back
// only when changed — the common unqualified CHECK is a no-op.
let col_checks: Vec<(String, String)> = {
let mut stmt = tx
.prepare(&format!(
"SELECT column_name, check_expr FROM {META_TABLE} \
WHERE table_name = ?1 AND check_expr IS NOT NULL;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([new], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.map_err(DbError::from_rusqlite)?;
let mut v = Vec::new();
for row in rows {
v.push(row.map_err(DbError::from_rusqlite)?);
}
v
};
for (column_name, expr) in col_checks {
let rewritten = rewrite_check_table_qualifier(&expr, old, new);
if rewritten != expr {
tx.execute(
&format!(
"UPDATE {META_TABLE} SET check_expr = ?1 \
WHERE table_name = ?2 AND column_name = ?3;"
),
rusqlite::params![rewritten, new, column_name],
)
.map_err(DbError::from_rusqlite)?;
}
}
let table_checks: Vec<(i64, String)> = {
let mut stmt = tx
.prepare(&format!(
"SELECT seq, check_expr FROM {CHECK_TABLE} WHERE table_name = ?1;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([new], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(DbError::from_rusqlite)?;
let mut v = Vec::new();
for row in rows {
v.push(row.map_err(DbError::from_rusqlite)?);
}
v
};
for (seq, expr) in table_checks {
let rewritten = rewrite_check_table_qualifier(&expr, old, new);
if rewritten != expr {
tx.execute(
&format!(
"UPDATE {CHECK_TABLE} SET check_expr = ?1 \
WHERE table_name = ?2 AND seq = ?3;"
),
rusqlite::params![rewritten, new, seq],
)
.map_err(DbError::from_rusqlite)?;
}
}
let description = do_describe_table(conn, new)?;
// (3) CSV follows the table: write `data/<new>.csv` from the renamed
// table (read in-tx by its new name) and delete `data/<old>.csv` — the
// existing rewrite+delete path; an empty table writes no CSV on either
// side. `schema_dirty` rewrites `project.yaml`, which reflects the new
// name automatically.
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![new.to_string()],
deleted_tables: vec![old.to_string()],
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Change a column's type.
///
/// Change a column's user-facing type, per ADR-0017's per-cell
/// transformer model.
///
/// SQLite's `ALTER TABLE` cannot change column types, so this
/// routes through the rebuild-table primitive (ADR-0013) with a
/// row-by-row transformation step inserted between read-out and
/// write-back.
///
/// Refusal preconditions (in order):
///
/// 1. Target is `serial` (auto-increment is create-table-only).
/// 2. Source ↔ target is statically refused per the matrix
/// (same-type, blob, date↔datetime, undefined cross-domain).
/// 3. Column is the *child* side of any relationship (outbound
/// FK) — drop the relationship first.
/// 4. Column has any inbound FK (parent side) and the new type
/// would change `fk_target_type()` (ADR-0017 §4.1) — which
/// means referencing FK columns in other tables would need a
/// cascading type change v1 doesn't perform.
///
/// Then per the mode:
///
/// - `Default`: dry-run; refuse on any incompatible cell;
/// refuse on any lossy cell (with a hint about
/// `--force-conversion`); enforce uniqueness for PK / shortid
/// columns; rebuild with transformed values.
/// - `ForceConversion`: same as Default except lossy cells are
/// accepted (incompatible cells still refuse).
/// - `DontConvert`: skip the entire client-side layer, identity
/// copy via `INSERT…SELECT`; engine-level errors are wrapped
/// in friendly form (ADR-0002 user-facing posture).
fn do_change_column_type(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
column: &str,
ty: Type,
mode: ChangeColumnMode,
) -> Result<ChangeColumnTypeResult, DbError> {
debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type");
// Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling
// column executors. Closes the simple `change column` exposure and
// the SQL `ALTER COLUMN TYPE` decomposition target (ADR-0035 §4f);
// user-confirmed 2026-05-25.
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
let col_info = old_schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table}.{column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let src_ty = col_info.user_type.ok_or_else(|| {
DbError::Unsupported(format!(
"cannot change type of `{table}.{column}`: column has no \
user-facing type metadata."
))
})?;
// (1) + (2): static refusals via the matrix.
if let Some(reason) = type_change::static_refusal(src_ty, ty) {
return Err(DbError::Unsupported(reason));
}
// (3): outbound FK (column is child side of any relationship).
let outbound_count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {REL_TABLE} \
WHERE child_table = ?1 AND child_column = ?2;"
),
rusqlite::params![table, column],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if outbound_count > 0 {
return Err(DbError::Unsupported(format!(
"cannot change the type of `{table}.{column}` — a relationship \
uses it as a foreign key, and changing its type could break \
the link to the table it references. Drop the relationship \
first, then change the type."
)));
}
// (4): inbound FKs (column is parent / target of any FK).
let inbound_count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {REL_TABLE} \
WHERE parent_table = ?1 AND parent_column = ?2;"
),
rusqlite::params![table, column],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if inbound_count > 0 && src_ty.fk_target_type() != ty.fk_target_type() {
return Err(DbError::Unsupported(format!(
"`{table}.{column}` is referenced by {inbound_count} relationship(s); \
changing its type to `{ty}` would change the type that \
referencing columns require — drop those relationships first \
or pick a target type whose foreign-key shape matches the \
current one."
)));
}
// Build new_schema: same as old, but the target column's
// user_type / sqlite_type are updated. PK / notnull flags
// carry over unchanged. UNIQUE handling per ADR-0018 §4:
//
// - Non-PK serial / shortid target: gain UNIQUE.
// - Reverse direction (e.g. serial → int): keep whatever
// UNIQUE flag was already present (ADR-0018 §4 "leaves
// UNIQUE in place"), which the clone() above already
// does.
let mut new_schema = old_schema.clone();
{
let target = new_schema
.columns
.iter_mut()
.find(|c| c.name == column)
.expect("column existence checked above");
target.user_type = Some(ty);
target.sqlite_type = ty.sqlite_strict_type().to_string();
if matches!(ty, Type::Serial | Type::ShortId) && !target.primary_key {
target.unique = true;
// Auto-generated columns are non-null by contract
// (ADR-0018 §3). Set the flag here so schema_to_ddl
// emits NOT NULL during the rebuild.
target.notnull = true;
}
}
let ty_keyword = ty.keyword();
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
tx.execute(
&format!(
"UPDATE {META_TABLE} SET user_type = ?1 \
WHERE table_name = ?2 AND column_name = ?3;"
),
[ty_keyword, table, column],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
};
let client_side = match mode {
ChangeColumnMode::DontConvert => {
// Skip the client-side layer entirely. The engine's
// STRICT typing will accept or refuse cells; we wrap
// any error in a friendly message (ADR-0017 §5,
// ADR-0002 user-facing posture).
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)
.map_err(|e| {
let ctx = crate::friendly::TranslateContext {
operation: Some(crate::friendly::Operation::ChangeColumnType),
table: Some(table.to_string()),
column: Some(column.to_string()),
src_type: Some(src_ty),
target_type: Some(ty),
..crate::friendly::TranslateContext::default()
};
let rendered =
crate::friendly::translate_error(&e, &ctx).render();
DbError::Unsupported(rendered)
})?;
None
}
ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some(
run_change_column_with_dry_run(
conn,
table,
column,
src_ty,
ty,
mode,
&old_schema,
&new_schema,
metadata_updates,
)?,
),
};
// Client-side note fires when at least one cell was
// materially transformed (ADR-0017 §6) OR at least one
// null cell was auto-filled (ADR-0018 §9). A pure metadata
// change (zero non-identity outcomes, no auto-fill) emits
// no note.
let client_side = client_side.filter(|note| note.transformed > 0 || note.auto_filled > 0);
let description = do_describe_table(conn, table)?;
Ok(ChangeColumnTypeResult {
description,
client_side,
})
}
/// Read every row from the source table, classify the target
/// column's cell via the transformer matrix, refuse on
/// incompatibles or lossy (per `mode`), check uniqueness for
/// PK / shortid, and finally rebuild the table with the
/// transformed values bound row-by-row.
///
/// Returns the per-row counts feeding the `[client-side]`
/// success note (ADR-0017 §6).
#[allow(clippy::too_many_arguments)]
fn run_change_column_with_dry_run<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> {
debug!("list_tables");
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)
}
/// Structured data to render one relationship's diagram (ADR-0044):
/// find the named relationship, then describe both endpoint tables.
/// `Ok(None)` when no relationship by that name exists (the App shows
/// a friendly not-found line).
fn do_show_relationship(
conn: &Connection,
name: &str,
) -> Result<Option<RelationshipDiagramData>, DbError> {
debug!(name = %name, "show_relationship");
let Some(rel) = read_all_relationships(conn)?
.into_iter()
.find(|r| r.name == name)
else {
return Ok(None);
};
let child = do_describe_table(conn, &rel.child_table)?;
let parent = do_describe_table(conn, &rel.parent_table)?;
Ok(Some(RelationshipDiagramData { rel, child, parent }))
}
/// Pre-formatted display lines for the `show <kind>` list commands
/// (V5). A count header followed by one indented item per line, or a
/// single friendly "none yet" line for an empty collection. Reuses
/// the same helpers the items panel / describe view read from, so the
/// list never drifts from those views. Engine-neutral wording per the
/// ADR-0002 user-facing posture.
fn do_show_list(
conn: &Connection,
kind: crate::dsl::command::ShowListKind,
name: Option<&str>,
) -> Result<Vec<String>, DbError> {
debug!(kind = ?kind, name = ?name, "show_list");
use crate::dsl::command::ShowListKind;
// V5a: a named item shows one relationship/index's detail.
if let Some(name) = name {
return do_show_one(conn, kind, name);
}
let mut lines = Vec::new();
match kind {
ShowListKind::Tables => {
let tables = do_list_tables(conn)?;
if tables.is_empty() {
lines.push("No tables in this project yet.".to_string());
} else {
lines.push(format!("Tables ({}):", tables.len()));
for name in tables {
lines.push(format!(" {name}"));
}
}
}
ShowListKind::Relationships => {
let rels = read_all_relationships(conn)?;
if rels.is_empty() {
lines.push("No relationships in this project yet.".to_string());
} else {
lines.push(format!("Relationships ({}):", rels.len()));
for r in rels {
let mut line = format!(
" {}: {}{}",
r.name,
fmt_rel_endpoint(&r.parent_table, &r.parent_columns),
fmt_rel_endpoint(&r.child_table, &r.child_columns),
);
if r.on_delete != ReferentialAction::default_action() {
line.push_str(&format!(" on delete {}", r.on_delete.keyword()));
}
if r.on_update != ReferentialAction::default_action() {
line.push_str(&format!(" on update {}", r.on_update.keyword()));
}
lines.push(line);
}
}
}
ShowListKind::Indexes => {
// Each table's user-created indexes (origin "c"), the
// same set the items panel shows. Ordered by table, then
// by index name (read_table_indexes orders by name).
let tables = do_list_tables(conn)?;
let mut entries: Vec<String> = Vec::new();
for table in &tables {
for ix in read_table_indexes(conn, table)? {
let unique = if ix.unique { " [unique]" } else { "" };
entries.push(format!(
" {}.{} ({}){unique}",
table,
ix.name,
ix.columns.join(", ")
));
}
}
if entries.is_empty() {
lines.push("No indexes in this project yet.".to_string());
} else {
lines.push(format!("Indexes ({}):", entries.len()));
lines.extend(entries);
}
}
}
Ok(lines)
}
/// Detail lines for one named relationship or index (V5a): a
/// labelled block, or a friendly "no such item" line. `Tables` is
/// never routed here (the table singular is `ShowTable`); the
/// defensive arm keeps the match total without a panic.
///
/// **The `Relationships` arm is superseded for the app by
/// `do_show_relationship` (ADR-0044): the runtime reroutes a named
/// `show relationship` to the structured diagram path, so this prose
/// form is no longer shown to users.** It is retained — reachable via
/// the `Database::show_list` worker API and covered by a worker test —
/// as a text fallback that could back a future non-visual display
/// option (cf. ADR-0044 OOS-7's relationship-display setting). The
/// `Indexes` arm remains live (`show index <name>` still routes here).
fn do_show_one(
conn: &Connection,
kind: crate::dsl::command::ShowListKind,
name: &str,
) -> Result<Vec<String>, DbError> {
debug!(kind = ?kind, name = %name, "show_one");
use crate::dsl::command::ShowListKind;
let mut lines = Vec::new();
match kind {
ShowListKind::Relationships => match read_all_relationships(conn)?
.into_iter()
.find(|r| r.name == name)
{
None => lines.push(format!("No relationship named `{name}`.")),
Some(r) => {
lines.push(format!("Relationship `{}`:", r.name));
lines.push(format!(
" {}{}",
fmt_rel_endpoint(&r.parent_table, &r.parent_columns),
fmt_rel_endpoint(&r.child_table, &r.child_columns),
));
lines.push(format!(" on delete {}", r.on_delete.keyword()));
lines.push(format!(" on update {}", r.on_update.keyword()));
}
},
ShowListKind::Indexes => {
// Find the user-created index by name across all tables.
let mut found = None;
for table in do_list_tables(conn)? {
if let Some(ix) = read_table_indexes(conn, &table)?
.into_iter()
.find(|ix| ix.name == name)
{
found = Some((table, ix));
break;
}
}
match found {
None => lines.push(format!("No index named `{name}`.")),
Some((table, ix)) => {
lines.push(format!("Index `{}` on {table}:", ix.name));
lines.push(format!(" columns: {}", ix.columns.join(", ")));
lines.push(format!(
" unique: {}",
if ix.unique { "yes" } else { "no" }
));
}
}
}
ShowListKind::Tables => {
lines.push(format!("No relationship or index named `{name}`."));
}
}
Ok(lines)
}
/// 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>,
/// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2),
/// read from the UNIQUE-constraint indexes (`origin = 'u'`).
/// Single-column UNIQUE rides on `ReadColumn::unique` instead.
unique_constraints: Vec<Vec<String>>,
/// Table-level CHECK constraints as raw SQL text with an optional
/// name, in declaration order (ADR-0035 §4a.3, named in §4g). The
/// engine reports no CHECK constraints, so these are read from
/// `__rdbms_playground_table_checks` rather than PRAGMA, and echoed
/// verbatim by `schema_to_ddl` on rebuild (`CONSTRAINT <name>` when
/// named).
check_constraints: Vec<TableCheck>,
}
#[derive(Debug, Clone)]
struct ReadColumn {
name: String,
sqlite_type: String,
notnull: bool,
primary_key: bool,
/// `true` when this column carries a single-column UNIQUE
/// constraint detected via `pragma_index_list` /
/// `pragma_index_info` (origin = "u"). PK columns are not
/// marked unique here even though PK implies UNIQUE — the
/// `primary_key` flag covers that, and `schema_to_ddl`
/// avoids double-emitting. Compound UNIQUE is out of scope
/// for v1 (ADR-0018 OOS-6 / future C3 work).
unique: bool,
/// The column's `DEFAULT` expression as SQLite reports it
/// (`pragma_table_info.dflt_value`) — already a SQL
/// literal, echoed verbatim by `schema_to_ddl` so the
/// rebuild dance preserves it (ADR-0029).
default_sql: Option<String>,
/// The column's `CHECK` constraint in compiled-SQL form
/// (ADR-0029 §7), read from the `check_expr` metadata
/// column — `pragma_table_info` does not expose CHECK.
/// Echoed verbatim by `schema_to_ddl`.
check: Option<String>,
user_type: Option<Type>,
}
#[derive(Debug, Clone)]
struct ReadForeignKey {
parent_table: String,
/// Parent + child column lists, positionally paired; one
/// element for single-column, ordered list for a compound FK
/// (ADR-0043).
parent_columns: Vec<String>,
child_columns: Vec<String>,
on_delete: ReferentialAction,
on_update: ReferentialAction,
}
fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
// Columns + PK from pragma_table_info, joined with our user-type metadata.
let mut col_stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \
pti.dflt_value, m.check_expr \
FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \
ORDER BY pti.cid;"
))
.map_err(DbError::from_rusqlite)?;
let rows = col_stmt
.query_map([table], |row| {
let user_type_kw: Option<String> = row.get(4)?;
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
Ok(ReadColumn {
name: row.get(0)?,
sqlite_type: row.get(1)?,
notnull: row.get::<_, i64>(2)? != 0,
primary_key: row.get::<_, i64>(3)? != 0,
unique: false, // filled in below from pragma_index_list
default_sql: row.get(5)?,
check: row.get(6)?,
user_type,
})
})
.map_err(DbError::from_rusqlite)?;
let mut columns = Vec::new();
for row in rows {
columns.push(row.map_err(DbError::from_rusqlite)?);
}
if columns.is_empty() {
return Err(DbError::Sqlite {
message: format!("no such table: {table}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
let primary_key: Vec<String> = columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.clone())
.collect();
// Detect UNIQUE constraints (ADR-0018 §4, ADR-0035 §4a.2).
// pragma_index_list returns one row per index; we filter to
// unique indexes whose origin is "u" (a UNIQUE constraint, as
// opposed to "pk" or "c"). Single-column → the column's `unique`
// flag; multi-column → a composite `unique_constraints` entry.
let (unique_columns, unique_constraints) = read_unique_constraints(conn, table)?;
for col in &mut columns {
if unique_columns.contains(&col.name) {
col.unique = true;
}
}
// Foreign keys from pragma_foreign_key_list. A *compound* FK
// (ADR-0043) is reported as several rows sharing one `id`, in
// `seq` order — so we group consecutive same-`id` rows into one
// `ReadForeignKey` with positionally-ordered column lists.
let mut fk_stmt = conn
.prepare(
"SELECT id, \"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| {
Ok((
row.get::<_, i64>(0)?, // id — groups a compound FK's columns
row.get::<_, String>(1)?, // parent table
row.get::<_, String>(2)?, // child column (`from`)
row.get::<_, String>(3)?, // parent column (`to`)
parse_action_from_sqlite(&row.get::<_, String>(4)?),
parse_action_from_sqlite(&row.get::<_, String>(5)?),
))
})
.map_err(DbError::from_rusqlite)?;
let mut foreign_keys: Vec<ReadForeignKey> = Vec::new();
let mut current_id: Option<i64> = None;
for row in fk_rows {
let (id, parent_table, child_col, parent_col, on_delete, on_update) =
row.map_err(DbError::from_rusqlite)?;
if current_id == Some(id) {
let fk = foreign_keys
.last_mut()
.expect("a same-id group always follows its first row");
fk.child_columns.push(child_col);
fk.parent_columns.push(parent_col);
} else {
current_id = Some(id);
foreign_keys.push(ReadForeignKey {
parent_table,
child_columns: vec![child_col],
parent_columns: vec![parent_col],
on_delete,
on_update,
});
}
}
// Table-level CHECK constraints (ADR-0035 §4a.3) come from their
// metadata table, not PRAGMA — the engine reports no CHECKs.
let check_constraints = read_table_checks(conn, table)?;
Ok(ReadSchema {
columns,
primary_key,
foreign_keys,
unique_constraints,
check_constraints,
})
}
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3, named
/// in §4g) from `CHECK_TABLE`, in declaration order (`seq`). The engine
/// exposes no PRAGMA for CHECK constraints, so this metadata table is
/// their only source of truth. Tolerates a pre-4g project whose table
/// predates the `name` column (rebuild-only migration) by reading the
/// name as `None`.
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<TableCheck>, DbError> {
let has_name = check_table_has_name_column(conn)?;
let sql = if has_name {
format!(
"SELECT check_expr, name FROM {CHECK_TABLE} \
WHERE table_name = ?1 ORDER BY seq;"
)
} else {
format!(
"SELECT check_expr FROM {CHECK_TABLE} \
WHERE table_name = ?1 ORDER BY seq;"
)
};
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([table], |row| {
let expr: String = row.get(0)?;
let name: Option<String> = if has_name { row.get(1)? } else { None };
Ok(TableCheck { name, expr })
})
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Whether `CHECK_TABLE` carries the `name` column (ADR-0035 §4g). A
/// pre-4g project's metadata table predates it — the column arrives on
/// `rebuild` (the rebuild-only migration, user-confirmed 2026-05-25).
/// Used to read names tolerantly and to refuse a *named* CHECK add on an
/// un-upgraded project with a friendly "rebuild first" message rather
/// than a raw engine error.
fn check_table_has_name_column(conn: &Connection) -> Result<bool, DbError> {
let count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
Ok(count > 0)
}
/// Whether the raw CHECK expression `check_expr` references the column
/// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard).
///
/// Tokenizes the expression with the shared lex helpers, **skipping
/// single-quoted string literals** so an identifier that only appears
/// inside a literal does not false-match, and compares each bare
/// identifier case-insensitively. Playground column names are always
/// valid bare identifiers (`validate_user_name` rejects the characters
/// that would force quoting), so bare-identifier scanning is sufficient;
/// the engine's native `DROP`/`RENAME COLUMN` remains the backstop for
/// any miss.
fn check_references_column(check_expr: &str, column: &str) -> bool {
use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal, skip_whitespace};
let mut i = 0;
while i < check_expr.len() {
i = skip_whitespace(check_expr, i);
if i >= check_expr.len() {
break;
}
if let Some(((_, end), _)) = consume_string_literal(check_expr, i) {
i = end;
} else if let Some((start, end)) = consume_ident(check_expr, i) {
if check_expr[start..end].eq_ignore_ascii_case(column) {
return true;
}
i = end;
} else {
// Operator / paren / number / punctuation — advance one char.
i += check_expr[i..].chars().next().map_or(1, char::len_utf8);
}
}
false
}
#[cfg(test)]
mod check_references_column_tests {
use super::check_references_column;
#[test]
fn detects_a_bare_identifier() {
assert!(check_references_column("price > 0", "price"));
assert!(check_references_column("a < b AND b < c", "b"));
}
#[test]
fn is_case_insensitive() {
assert!(check_references_column("Price > 0", "price"));
assert!(check_references_column("price > 0", "PRICE"));
}
#[test]
fn ignores_identifier_inside_a_string_literal() {
// `status` appears only inside a literal → not a reference.
assert!(!check_references_column("kind <> 'status'", "status"));
// A genuine reference alongside a literal is still found.
assert!(check_references_column("status <> 'archived'", "status"));
}
#[test]
fn ignores_a_longer_identifier_that_merely_contains_the_name() {
// `price` is a substring of these idents but not a whole match.
assert!(!check_references_column("price_cents > 0", "price"));
assert!(!check_references_column("unit_price > 0", "price"));
}
}
/// Rewrite the old table name where it is used as a **qualifier** in a
/// CHECK expression (the identifier immediately followed by `.`) to the
/// new name — both the bare (`old.col`) and double-quoted (`"old".col`)
/// forms, case-insensitively — leaving everything else byte-for-byte
/// intact (ADR-0035 §6, sub-phase 4h).
///
/// **Why this is needed.** A native `ALTER TABLE old RENAME TO new`
/// rewrites table-qualified column references inside the renamed table's
/// *live* CHECK (`CHECK (T.age>0)` → `CHECK ("U".age>0)`), but our
/// *stored* CHECK text would keep the old name and break a later rebuild
/// (`schema_to_ddl` would emit `CHECK (T.age>0)` for a table now named
/// `U` → "no such table T"). This keeps the stored text in step with the
/// live schema. **Bounded problem:** a CHECK may reference only its own
/// table's columns (SQLite forbids subqueries / other tables in a CHECK),
/// so the only table qualifier that can appear is the renamed table's
/// name — the rewrite target is unambiguous.
///
/// Extends the [`check_references_column`] tokenizer: skips single-quoted
/// string literals (a literal containing the old name is untouched), and
/// a bare identifier equal to the old name but **not** used as a qualifier
/// (not followed by `.` — e.g. a column literally named like the table)
/// is left alone, so the common unqualified CHECK (`age > 0`) is a no-op.
fn rewrite_check_table_qualifier(check_expr: &str, old: &str, new: &str) -> String {
use crate::dsl::walker::lex_helpers::{consume_ident, consume_string_literal};
let bytes = check_expr.as_bytes();
// Whether the next byte at `pos` begins a `.` qualifier separator.
let dot_follows = |pos: usize| bytes.get(pos) == Some(&b'.');
let mut out = String::with_capacity(check_expr.len());
let mut i = 0;
while i < check_expr.len() {
// Single-quoted string literal — copy verbatim, never rewrite.
if let Some(((start, end), _)) = consume_string_literal(check_expr, i) {
out.push_str(&check_expr[start..end]);
i = end;
continue;
}
// Double-quoted identifier — scan to the closing quote (`""` is an
// escaped quote). Rewrite when it is the old name used as a
// qualifier; table names never contain quotes, so the raw inner
// text suffices for comparison.
if bytes[i] == b'"' {
let mut j = i + 1;
while j < check_expr.len() {
if bytes[j] == b'"' {
if bytes.get(j + 1) == Some(&b'"') {
j += 2; // escaped quote inside the identifier
continue;
}
break; // closing quote
}
j += 1;
}
let end = (j + 1).min(check_expr.len()); // byte after closing `"`
let inner = &check_expr[i + 1..j.min(check_expr.len())];
if inner.eq_ignore_ascii_case(old) && dot_follows(end) {
out.push('"');
out.push_str(new);
out.push('"');
} else {
out.push_str(&check_expr[i..end]);
}
i = end;
continue;
}
// Bare identifier.
if let Some((start, end)) = consume_ident(check_expr, i) {
if check_expr[start..end].eq_ignore_ascii_case(old) && dot_follows(end) {
out.push_str(new);
} else {
out.push_str(&check_expr[start..end]);
}
i = end;
continue;
}
// Operator / paren / number / punctuation — copy one char.
let ch_len = check_expr[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&check_expr[i..i + ch_len]);
i += ch_len;
}
out
}
#[cfg(test)]
mod rewrite_check_table_qualifier_tests {
use super::rewrite_check_table_qualifier;
#[test]
fn rewrites_a_bare_qualifier() {
assert_eq!(rewrite_check_table_qualifier("T.age > 0", "T", "U"), "U.age > 0");
// multiple occurrences, table-level CHECK shape
assert_eq!(rewrite_check_table_qualifier("T.a <> T.b", "T", "U"), "U.a <> U.b");
}
#[test]
fn is_case_insensitive_on_the_qualifier() {
assert_eq!(rewrite_check_table_qualifier("t.age > 0", "T", "U"), "U.age > 0");
}
#[test]
fn rewrites_a_quoted_qualifier() {
assert_eq!(
rewrite_check_table_qualifier("\"T\".age > 0", "T", "U"),
"\"U\".age > 0"
);
}
#[test]
fn leaves_string_literals_untouched() {
assert_eq!(
rewrite_check_table_qualifier("note <> 'T.x' AND T.age > 0", "T", "U"),
"note <> 'T.x' AND U.age > 0"
);
}
#[test]
fn leaves_a_bare_name_that_is_not_a_qualifier() {
// A column literally named like the table, not followed by `.`.
assert_eq!(rewrite_check_table_qualifier("T > 0", "T", "U"), "T > 0");
}
#[test]
fn unqualified_check_is_a_no_op() {
assert_eq!(rewrite_check_table_qualifier("age > 0", "T", "U"), "age > 0");
}
#[test]
fn does_not_match_a_longer_identifier() {
// `Total` merely starts with `T`; not the qualifier `T`.
assert_eq!(
rewrite_check_table_qualifier("Total.x > 0", "T", "U"),
"Total.x > 0"
);
}
}
/// Whether any CHECK constraint on `table` references `column` — both the
/// table-level CHECKs (`read_table_checks`) and the column-level CHECKs
/// (`schema.columns[].check`), the guard for drop/rename column
/// (ADR-0035 §4e).
///
/// `include_self` controls whether the column's *own* column-level CHECK
/// counts: a RENAME rewrites the live CHECK but leaves the stored text
/// stale (drift) even for a self-check, so it must be included; a DROP
/// removes the column's own CHECK alongside it, so it is excluded (only a
/// *cross-referencing* CHECK blocks the drop).
fn column_referenced_by_check(
conn: &Connection,
table: &str,
schema: &ReadSchema,
column: &str,
include_self: bool,
) -> Result<bool, DbError> {
for check in read_table_checks(conn, table)? {
if check_references_column(&check.expr, column) {
return Ok(true);
}
}
for col in &schema.columns {
if !include_self && col.name == column {
continue;
}
if let Some(check) = &col.check
&& check_references_column(check, column)
{
return Ok(true);
}
}
Ok(false)
}
/// Read the user-created indexes on `table` (ADR-0025).
///
/// `pragma_index_list` reports every index; we keep only those
/// with origin `c` (a `CREATE INDEX` statement) and skip partial
/// indexes — the playground never creates partial indexes, and
/// surfacing the automatic PK / UNIQUE indexes as user indexes
/// would be misleading. Results are ordered by index name for
/// stable rendering.
fn read_table_indexes(conn: &Connection, table: &str) -> Result<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).
/// Read the table's `UNIQUE` constraints (ADR-0018 §4, ADR-0035
/// §4a.2) from the constraint-backing indexes (`origin = 'u'`).
/// Returns `(single_column_names, composite_constraints)`: a
/// single-column UNIQUE rides on the column's `unique` flag, while a
/// multi-column UNIQUE becomes a `Vec<String>` of its columns (in
/// index order).
fn read_unique_constraints(
conn: &Connection,
table: &str,
) -> Result<(std::collections::HashSet<String>, Vec<Vec<String>>), DbError> {
let mut single: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut composite: Vec<Vec<String>> = Vec::new();
let mut idx_stmt = conn
.prepare(
"SELECT name, \"unique\", origin \
FROM pragma_index_list(?1);",
)
.map_err(DbError::from_rusqlite)?;
let idx_rows = idx_stmt
.query_map([table], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? != 0,
row.get::<_, String>(2)?,
))
})
.map_err(DbError::from_rusqlite)?;
let indexes: Vec<(String, bool, String)> = idx_rows
.collect::<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) ORDER BY seqno;")
.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)?;
match cols.len() {
0 => {}
1 => {
single.insert(cols.into_iter().next().expect("len 1"));
}
_ => composite.push(cols),
}
}
Ok((single, composite))
}
/// Engine-neutral display/address name for an anonymous composite UNIQUE
/// constraint (ADR-0035 Amendment 1): `unique_<col1>_<col2>…`. A pure
/// function of the column list — recomputed wherever the name is shown
/// (`describe`) or matched (`ALTER TABLE … DROP CONSTRAINT <name>`), so
/// nothing is persisted; the constraint stays a bare column-list in our
/// model and the §4g anonymity decision is intact.
pub(crate) fn unique_constraint_name(cols: &[String]) -> String {
format!("unique_{}", cols.join("_"))
}
/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during
/// the rebuild dance.
fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
let mut clauses: Vec<String> = Vec::new();
// The single-column-INTEGER-PK case must be inline (`PRIMARY
// KEY` on the column itself) so SQLite gives it rowid-alias
// semantics. Compound or non-INTEGER PKs go to a table-level
// constraint.
let single_inline_pk = schema.primary_key.len() == 1
&& !schema.columns.is_empty()
&& schema.columns[0].primary_key
&& schema.primary_key[0] == schema.columns[0].name;
for col in &schema.columns {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
sqlite_type = col.sqlite_type,
);
if col.notnull {
clause.push_str(" NOT NULL");
}
if single_inline_pk && col.primary_key {
clause.push_str(" PRIMARY KEY");
}
// Inline UNIQUE for columns flagged unique (ADR-0018 §4,
// ADR-0029 §9). A *single-column* PK is already UNIQUE
// via PRIMARY KEY, so suppress a redundant index there;
// a *compound*-PK member is not individually unique, so
// an explicit UNIQUE on it is a real, distinct rule.
let single_column_pk = schema.primary_key.len() == 1;
if col.unique && !(col.primary_key && single_column_pk) {
clause.push_str(" UNIQUE");
}
// ADR-0029 DEFAULT — echoed verbatim from the value
// SQLite reported, so the rebuild dance preserves it.
if let Some(default_sql) = &col.default_sql {
clause.push_str(" DEFAULT ");
clause.push_str(default_sql);
}
// ADR-0029 CHECK — echoed verbatim from the compiled
// SQL stored in the `check_expr` metadata column.
if let Some(check) = &col.check {
clause.push_str(" CHECK (");
clause.push_str(check);
clause.push(')');
}
clauses.push(clause);
}
if !single_inline_pk && !schema.primary_key.is_empty() {
let pk_idents: Vec<String> = schema.primary_key.iter().map(|n| quote_ident(n)).collect();
clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", ")));
}
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
// emitted identically to `do_create_table` so a created table and
// its rebuilt form match.
for cols in &schema.unique_constraints {
let idents: Vec<String> = cols.iter().map(|n| quote_ident(n)).collect();
clauses.push(format!("UNIQUE ({})", idents.join(", ")));
}
// Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
// from the raw SQL stored in the metadata table, emitted identically
// to `do_create_table` (the §6.1 two-generators rule). A named CHECK
// (ADR-0035 §4g) re-emits its `CONSTRAINT <name>` prefix so the name
// round-trips through a rebuild.
for check in &schema.check_constraints {
match &check.name {
Some(name) => clauses.push(format!(
"CONSTRAINT {ident} CHECK ({expr})",
ident = quote_ident(name),
expr = check.expr,
)),
None => clauses.push(format!("CHECK ({expr})", expr = check.expr)),
}
}
for fk in &schema.foreign_keys {
// Multi-column FK (ADR-0043): emit the positionally-paired
// column lists `FOREIGN KEY (a, b) REFERENCES P(x, y)`. A
// single-column FK is the one-element case.
clauses.push(format!(
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
ON DELETE {od} ON UPDATE {ou}",
child = quote_cols(&fk.child_columns),
parent_table = quote_ident(&fk.parent_table),
parent_col = quote_cols(&fk.parent_columns),
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>,
{
debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)");
// 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)? {
warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back");
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)?;
debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)");
Ok(())
})();
// Always re-enable foreign_keys, even on error.
let pragma_result = conn
.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(DbError::from_rusqlite);
if let Err(e) = &pragma_result {
warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild");
}
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,
)
}
/// Auto-name a relationship per ADR-0013
/// (`<Parent>_<pcol>_to_<Child>_<ccol>`, reading in the declared
/// direction) when `name` is `None`; otherwise use the supplied name.
/// Shared by the DSL `add relationship` path and advanced-mode SQL
/// `CREATE TABLE` foreign keys (ADR-0035 §5).
fn resolve_relationship_name(
name: Option<&str>,
parent_table: &str,
parent_columns: &[String],
child_table: &str,
child_columns: &[String],
) -> String {
// Auto-name joins each side's column list with `_` (ADR-0043):
// a compound FK becomes `Parent_a_b_to_Child_x_y`; a
// single-column FK is unchanged (`Parent_a_to_Child_x`).
name.map_or_else(
|| {
format!(
"{parent_table}_{p}_to_{child_table}_{c}",
p = parent_columns.join("_"),
c = child_columns.join("_"),
)
},
ToString::to_string,
)
}
/// Reject a relationship name that collides with an existing one (the
/// `name` column is UNIQUE, ADR-0013). Engine-neutral message.
fn ensure_relationship_name_unique(conn: &Connection, name: &str) -> Result<(), DbError> {
let collision: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
[name],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if collision > 0 {
return Err(DbError::Unsupported(format!(
"a relationship named `{name}` already exists. \
Pick a different name or drop the existing one first."
)));
}
Ok(())
}
/// Insert one relationship metadata row into `REL_TABLE` (ADR-0013).
/// Shared by `add relationship` (inside its rebuild transaction) and
/// SQL `CREATE TABLE` foreign keys (inside the create transaction);
/// `conn` may be a `&Transaction` (deref-coerces to `&Connection`).
#[allow(clippy::too_many_arguments)]
fn insert_relationship_metadata(
conn: &Connection,
name: &str,
parent_table: &str,
parent_columns: &[String],
child_table: &str,
child_columns: &[String],
on_delete: ReferentialAction,
on_update: ReferentialAction,
) -> Result<(), DbError> {
conn.execute(
&format!(
"INSERT INTO {REL_TABLE} \
(name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
),
[
name,
parent_table,
&encode_rel_columns(parent_columns),
child_table,
&encode_rel_columns(child_columns),
on_delete.keyword(),
on_update.keyword(),
],
)
.map_err(DbError::from_rusqlite)?;
Ok(())
}
/// Encode a relationship's column list into a metadata `TEXT` cell
/// (ADR-0043 D5): comma-joined. Safe because column identifiers are
/// `[A-Za-z0-9_]+` (no commas — parser.rs); a single-column FK
/// stores just the bare name, a compound one stores `a,b`.
fn encode_rel_columns(cols: &[String]) -> String {
cols.join(",")
}
/// Inverse of [`encode_rel_columns`]: split a metadata cell back
/// into the ordered column list (one element for single-column).
fn decode_rel_columns(s: &str) -> Vec<String> {
s.split(',').map(str::to_string).collect()
}
/// Format a relationship endpoint for display (ADR-0043): `T.col`
/// for a single column, `T.(a, b)` for a compound one — mirroring
/// the DSL `from P.(a, b)` syntax.
fn fmt_rel_endpoint(table: &str, cols: &[String]) -> String {
if cols.len() == 1 {
format!("{table}.{}", cols[0])
} else {
format!("{table}.({})", cols.join(", "))
}
}
/// Validate that an FK child column's type is compatible with the
/// referenced parent column's type — it must equal the parent type's
/// `fk_target_type()` (ADR-0011). Engine-neutral mismatch error.
fn check_fk_type_compat(
parent_table: &str,
parent_column: &str,
parent_type: Type,
child_table: &str,
child_column: &str,
child_type: Type,
) -> Result<(), DbError> {
let expected = parent_type.fk_target_type();
if child_type != expected {
return Err(DbError::Unsupported(format!(
"type mismatch: `{child_table}.{child_column}` is `{child_type}` but \
a foreign key referencing `{parent_table}.{parent_column}` \
(`{parent_type}`) requires `{expected}`. \
Either change the column type, or pick a different FK column."
)));
}
Ok(())
}
/// Resolve a foreign key's parent (referenced) column list and check
/// it pairs with the child column list (ADR-0043).
///
/// - **F-A (full PK):** an *explicit* `REFERENCES P(...)` list must be
/// exactly the parent's primary-key column **set** (any order;
/// paired positionally with the child list). A subset, super-set,
/// or non-PK column is refused (UNIQUE-target / subset FKs are OOS).
/// - **F-D (auto-expand):** a *bare* `REFERENCES P` (`explicit ==
/// None`) expands to the parent's full PK in PK order, requiring the
/// child arity to match.
/// - Arity: the returned parent list and the child list must be
/// equal, non-zero length.
fn resolve_fk_parent_columns(
parent_table: &str,
parent_pk: &[String],
explicit: Option<&[String]>,
child_arity: usize,
inline: bool,
) -> Result<Vec<String>, DbError> {
if child_arity == 0 {
return Err(DbError::Unsupported(
"a foreign key needs at least one column.".to_string(),
));
}
let parent_columns = match explicit {
None => {
if parent_pk.is_empty() {
return Err(DbError::Unsupported(format!(
"`{parent_table}` has no primary key to reference."
)));
}
parent_pk.to_vec()
}
Some(cols) => {
use std::collections::BTreeSet;
let pk: BTreeSet<&String> = parent_pk.iter().collect();
let given: BTreeSet<&String> = cols.iter().collect();
if pk != given {
return Err(DbError::Unsupported(format!(
"a foreign key must reference `{parent_table}`'s full primary \
key ({pk_list}); got ({given_list}). Referencing a subset, or \
a non-primary-key column, is not supported.",
pk_list = parent_pk.join(", "),
given_list = cols.join(", "),
)));
}
cols.to_vec()
}
};
if parent_columns.len() != child_arity {
// An inline column-level FK (`<col> REFERENCES …`) can only carry
// the one column it sits on, so it can never satisfy a compound
// key — point the user at the table-level form rather than the
// generic arity message (ADR-0043 D4).
if inline && parent_columns.len() > 1 {
return Err(DbError::Unsupported(format!(
"an inline column reference can only name one column, but \
`{parent_table}`'s key has {n}. Use the table-level form \
instead: `FOREIGN KEY (<columns>) REFERENCES \
{parent_table} ({pk})`.",
n = parent_columns.len(),
pk = parent_columns.join(", "),
)));
}
return Err(DbError::Unsupported(format!(
"{child_arity} foreign-key column(s) on the child side, but \
`{parent_table}`'s key has {n}. A foreign key references every \
column of the key, paired in order.",
n = parent_columns.len(),
)));
}
Ok(parent_columns)
}
/// A `CREATE TABLE` foreign key after resolution + validation
/// (ADR-0035 §5, sub-phase 4b): the bare-`REFERENCES` parent column is
/// resolved, the relationship name is decided, and PK-target /
/// type-compat are checked.
struct ResolvedFk {
name: String,
child_columns: Vec<String>,
parent_table: String,
parent_columns: Vec<String>,
on_delete: ReferentialAction,
on_update: ReferentialAction,
}
/// Resolve + validate every foreign key declared in a `CREATE TABLE`
/// (ADR-0035 §5, sub-phase 4b) **before** the table is built, so an
/// invalid reference aborts cleanly. A self-referencing FK (parent is
/// the table being created) is validated against the columns/PK being
/// defined; any other parent must already exist. The bare
/// `REFERENCES <parent>` form resolves to the parent's single-column
/// PK (composite → error). Reuses the relationship validation/naming
/// helpers shared with `do_add_relationship`.
fn resolve_create_table_fks(
conn: &Connection,
table_name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
foreign_keys: &[SqlForeignKey],
) -> Result<Vec<ResolvedFk>, DbError> {
let mut out = Vec::with_capacity(foreign_keys.len());
for fk in foreign_keys {
// The parent's PK column list + a (name -> user type) lookup.
// A self-reference reads the in-statement specs (the table does
// not exist yet); any other parent must already exist.
let (parent_pk, parent_cols): (Vec<String>, Vec<(String, Option<Type>)>) =
if fk.parent_table == table_name {
(
primary_key.to_vec(),
columns.iter().map(|c| (c.name.clone(), Some(c.ty))).collect(),
)
} else {
let ps = read_schema(conn, &fk.parent_table)?;
(
ps.primary_key.clone(),
ps.columns
.iter()
.map(|c| (c.name.clone(), c.user_type))
.collect(),
)
};
// Resolve the parent column list: explicit must be the full PK
// set (F-A); bare auto-expands to the PK (F-D). Arity-checked
// against the child column count (ADR-0043).
let parent_columns = resolve_fk_parent_columns(
&fk.parent_table,
&parent_pk,
fk.parent_columns.as_deref(),
fk.child_columns.len(),
fk.inline,
)?;
// Each child column must be one of the columns being defined,
// and each pair must be type-compatible (ADR-0011, per pair).
for (child_col, parent_col) in fk.child_columns.iter().zip(&parent_columns) {
let child = columns
.iter()
.find(|c| &c.name == child_col)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {table_name}.{child_col}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let parent_type = parent_cols
.iter()
.find(|(n, _)| n == parent_col)
.and_then(|(_, t)| *t)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {}.{parent_col}", fk.parent_table),
kind: SqliteErrorKind::NoSuchColumn,
})?;
check_fk_type_compat(
&fk.parent_table,
parent_col,
parent_type,
table_name,
child_col,
child.ty,
)?;
}
let resolved_name = resolve_relationship_name(
fk.name.as_deref(),
&fk.parent_table,
&parent_columns,
table_name,
&fk.child_columns,
);
ensure_relationship_name_unique(conn, &resolved_name)?;
out.push(ResolvedFk {
name: resolved_name,
child_columns: fk.child_columns.clone(),
parent_table: fk.parent_table.clone(),
parent_columns,
on_delete: fk.on_delete,
on_update: fk.on_update,
});
}
Ok(out)
}
/// Generate a junction table for an m:n relationship between `t1` and
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
/// compound PK over all of them, and two `CASCADE` foreign keys, then
/// hands the whole thing to [`do_create_table`] — so the junction table
/// and both relationships are created in one transaction = one undo
/// step. Self-referential m:n is refused (column-name collision); a
/// PK-less parent is refused (nothing to reference).
fn do_create_m2n_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
t1: &str,
t2: &str,
name: Option<&str>,
) -> Result<TableDescription, DbError> {
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
// Canonicalize both parents (refuse non-existent / internal tables).
let canon_t1 = require_canonical_table(conn, t1)?;
let t1 = canon_t1.as_str();
let canon_t2 = require_canonical_table(conn, t2)?;
let t2 = canon_t2.as_str();
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
// would collide on `{T}_{pkcol}`, needing directional names this
// beginner convenience deliberately avoids.
if t1.eq_ignore_ascii_case(t2) {
return Err(DbError::Unsupported(format!(
"an m:n relationship needs two different tables (got `{t1}` twice). \
To link a table to itself, build the junction table by hand."
)));
}
let schema1 = read_schema(conn, t1)?;
let schema2 = read_schema(conn, t2)?;
// Build one FK column per parent PK column (compound parents
// contribute one each, ADR-0043) + the compound PK + the two FKs.
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = Vec::new();
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
// table; it cannot anchor an m:n relationship.
if schema.primary_key.is_empty() {
return Err(DbError::Unsupported(format!(
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
)));
}
let mut child_columns: Vec<String> = Vec::new();
for pkcol in &schema.primary_key {
let pcol = schema
.columns
.iter()
.find(|c| &c.name == pkcol)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {tbl}.{pkcol}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let pty = pcol.user_type.ok_or_else(|| {
DbError::Unsupported("primary-key column has no user type metadata".to_string())
})?;
let col_name = format!("{tbl}_{pkcol}");
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
primary_key.push(col_name.clone());
child_columns.push(col_name);
}
foreign_keys.push(SqlForeignKey {
name: None,
child_columns,
parent_table: tbl.to_string(),
parent_columns: Some(schema.primary_key.clone()),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::Cascade,
inline: false,
});
}
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
do_create_table(
conn,
persistence,
source,
&junction,
&columns,
&primary_key,
&[],
&[],
&foreign_keys,
)
}
#[allow(clippy::too_many_arguments)]
fn do_add_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: Option<&str>,
parent_table: &str,
parent_columns: &[String],
child_table: &str,
child_columns: &[String],
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
) -> Result<TableDescription, DbError> {
debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship");
// Canonicalize both endpoints to their stored case (and refuse a
// non-existent / internal `__rdbms_*` table as "no such table"), like
// the sibling schema-mutation executors — so the relationship metadata
// stores the stored-case names and `describe` / rebuild match them.
// Closes the simple `add 1:n relationship` exposure and the SQL `ALTER
// TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g).
let canonical_parent = require_canonical_table(conn, parent_table)?;
let parent_table = canonical_parent.as_str();
let canonical_child = require_canonical_table(conn, child_table)?;
let child_table = canonical_child.as_str();
// 1. Read parent schema; resolve+validate the referenced columns
// are the parent's full PK (F-A), arity-matched to the child
// list (ADR-0043). The DSL always supplies explicit columns.
let parent_schema = read_schema(conn, parent_table)?;
let parent_columns = resolve_fk_parent_columns(
parent_table,
&parent_schema.primary_key,
Some(parent_columns),
child_columns.len(),
false, // DSL `add relationship` is never an inline column FK
)?;
// 2. Read child schema; refuse missing columns unless --create-fk.
let mut child_schema = read_schema(conn, child_table)?;
let missing: Vec<&String> = child_columns
.iter()
.filter(|c| child_schema.columns.iter().all(|col| &col.name != *c))
.collect();
if !missing.is_empty() && !create_fk {
let list = missing
.iter()
.map(|c| format!("`{child_table}.{c}`"))
.collect::<Vec<_>>()
.join(", ");
return Err(DbError::Unsupported(format!(
"column(s) {list} do not exist. Add them first, or use \
`--create-fk` to create them automatically."
)));
}
// 3. Per pair: resolve the parent column's user type, then either
// validate the existing child column's type (ADR-0011 per pair)
// or synthesise a new child column typed to the parent's
// fk_target_type (--create-fk, one per missing column).
let mut created: Vec<(String, Type)> = Vec::new();
for (child_col, parent_col) in child_columns.iter().zip(&parent_columns) {
let pcol = parent_schema
.columns
.iter()
.find(|c| &c.name == parent_col)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {parent_table}.{parent_col}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let parent_user_type = pcol.user_type.ok_or_else(|| {
DbError::Unsupported("parent column has no user type metadata".to_string())
})?;
match child_schema.columns.iter().find(|c| &c.name == child_col) {
Some(child_col_row) => {
let actual = child_col_row.user_type.ok_or_else(|| {
DbError::Unsupported("child column has no user type metadata".to_string())
})?;
check_fk_type_compat(
parent_table,
parent_col,
parent_user_type,
child_table,
child_col,
actual,
)?;
}
None => created.push((child_col.clone(), parent_user_type.fk_target_type())),
}
}
// Synthesise the new child columns into the old schema (so rebuild
// builds the new table with them), mirroring the single-column path.
for (col, ty) in &created {
child_schema.columns.push(ReadColumn {
name: col.clone(),
sqlite_type: ty.sqlite_strict_type().to_string(),
notnull: false,
primary_key: false,
unique: false,
default_sql: None,
check: None,
user_type: Some(*ty),
});
}
// 4. Determine relationship name (auto-gen or supplied) and
// check uniqueness against the metadata table.
let resolved_name = resolve_relationship_name(
name,
parent_table,
&parent_columns,
child_table,
child_columns,
);
ensure_relationship_name_unique(conn, &resolved_name)?;
// 5. Build the new schema with the (single, multi-column) FK appended.
let mut new_schema = child_schema.clone();
new_schema.foreign_keys.push(ReadForeignKey {
parent_table: parent_table.to_string(),
parent_columns: parent_columns.clone(),
child_columns: child_columns.to_vec(),
on_delete,
on_update,
});
// 6. Rebuild, with metadata updates inside the transaction.
rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| {
for (col, ty) in &created {
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[child_table, col.as_str(), ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
}
insert_relationship_metadata(
tx,
&resolved_name,
parent_table,
&parent_columns,
child_table,
child_columns,
on_delete,
on_update,
)?;
// Persistence runs inside the same tx so a write
// failure rolls back both the schema and the metadata
// (commit-db-last per ADR-0015 §6).
let changes = Changes {
schema_dirty: true,
// The child table was rebuilt — its CSV needs to
// be re-emitted from the new state too, in case
// the FK column was newly created.
rewritten_tables: vec![child_table.to_string()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
// Return the parent (1-side) description so the user sees
// the new relationship via the inbound section — that's the
// perspective that matches the `from <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> {
debug!(selector = ?selector, "drop_relationship");
// 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_columns != decode_rel_columns(&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)?))
}
/// `ALTER TABLE <T> ADD [CONSTRAINT <name>] CHECK (<expr>)` (ADR-0035
/// §4g). A dry-run refuses the add if any existing row fails the
/// predicate; the rebuild then re-emits the table with the new CHECK in
/// its DDL and records it in `CHECK_TABLE`. A *named* CHECK on a pre-4g
/// project (whose metadata table predates the `name` column — the
/// rebuild-only migration) is refused with a friendly "rebuild first"
/// message rather than a raw engine error.
fn do_alter_add_table_check(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
name: Option<&str>,
expr_sql: &str,
) -> Result<TableDescription, DbError> {
debug!(table = %table, name = ?name, "alter_add_table_check");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
if name.is_some() && !check_table_has_name_column(conn)? {
return Err(DbError::Unsupported(
"this project predates named constraints; run `rebuild` to \
upgrade it, then add the named constraint again."
.to_string(),
));
}
// A named CHECK must not collide with an existing CHECK name on this
// table NOR with a relationship name (FKs are also `DROP CONSTRAINT`
// targets) — keeps `drop constraint <name>` unambiguous.
if let Some(n) = name {
let collides_check = old_schema
.check_constraints
.iter()
.any(|c| c.name.as_deref() == Some(n));
let collides_rel: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
[n],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if collides_check || collides_rel > 0 {
return Err(DbError::Unsupported(format!(
"a constraint named `{n}` already exists on `{table}`."
)));
}
}
// Dry-run: a CHECK passes on TRUE or NULL; only FALSE fails, so
// `WHERE NOT (expr)` counts the genuine violations.
let violating: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {tbl} WHERE NOT ({expr_sql});",
tbl = quote_ident(table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if violating > 0 {
return Err(DbError::Unsupported(format!(
"cannot add CHECK ({expr_sql}) to `{table}`: {violating} existing \
row(s) do not satisfy it."
)));
}
let mut new_schema = old_schema.clone();
new_schema.check_constraints.push(TableCheck {
name: name.map(ToString::to_string),
expr: expr_sql.to_string(),
});
let table_owned = table.to_string();
let name_owned = name.map(ToString::to_string);
let expr_owned = expr_sql.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
// MAX(seq)+1 avoids colliding with a gap a prior DROP left.
let next_seq: i64 = tx
.query_row(
&format!(
"SELECT COALESCE(MAX(seq), -1) + 1 FROM {CHECK_TABLE} \
WHERE table_name = ?1;"
),
[table_owned.as_str()],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
VALUES (?1, ?2, ?3, ?4);"
),
rusqlite::params![table_owned, next_seq, expr_owned, name_owned],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
do_describe_table(conn, table)
}
/// `ALTER TABLE <T> ADD UNIQUE (<col>, …)` (ADR-0035 §4g) — a composite
/// UNIQUE constraint (anonymous: composite UNIQUE is PRAGMA-detected on
/// read, ADR-0035 §4a.2, so it carries no name). A dry-run refuses the
/// add if existing rows already contain a duplicate non-NULL tuple
/// (NULLs are distinct under SQL's UNIQUE semantics).
fn do_alter_add_unique(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
columns: &[String],
) -> Result<TableDescription, DbError> {
debug!(table = %table, cols = ?columns, "alter_add_unique");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?;
for c in columns {
if !old_schema.columns.iter().any(|oc| &oc.name == c) {
return Err(DbError::Sqlite {
message: format!("no such column: {table}.{c}"),
kind: SqliteErrorKind::NoSuchColumn,
});
}
}
let non_null = columns
.iter()
.map(|c| format!("{} IS NOT NULL", quote_ident(c)))
.collect::<Vec<_>>()
.join(" AND ");
let group = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let dup_groups: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM (SELECT 1 FROM {tbl} WHERE {non_null} \
GROUP BY {group} HAVING COUNT(*) > 1);",
tbl = quote_ident(table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if dup_groups > 0 {
return Err(DbError::Unsupported(format!(
"cannot add UNIQUE ({}) to `{table}`: existing rows contain \
duplicate values.",
columns.join(", "),
)));
}
let mut new_schema = old_schema.clone();
new_schema.unique_constraints.push(columns.to_vec());
let table_owned = table.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
do_describe_table(conn, table)
}
/// `ALTER TABLE <T> DROP CONSTRAINT <name>` (ADR-0035 §4g). Resolves
/// `name` to a named table-level CHECK on `T` (rebuild without it +
/// delete the metadata row), else to a named relationship (FK) whose
/// child is `T` (via `do_drop_relationship`), else refuses.
fn do_drop_constraint_by_name(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
name: &str,
) -> Result<Option<TableDescription>, DbError> {
debug!(table = %table, name = %name, "drop_constraint_by_name");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
// 1. A named table-level CHECK on this table?
if check_table_has_name_column(conn)? {
let check_count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {CHECK_TABLE} \
WHERE table_name = ?1 AND name = ?2;"
),
[table, name],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if check_count > 0 {
let old_schema = read_schema(conn, table)?;
let mut new_schema = old_schema.clone();
new_schema
.check_constraints
.retain(|c| c.name.as_deref() != Some(name));
let (t, n) = (table.to_string(), name.to_string());
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
tx.execute(
&format!(
"DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;"
),
[t.as_str(), n.as_str()],
)
.map_err(DbError::from_rusqlite)?;
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![t.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
return Ok(Some(do_describe_table(conn, table)?));
}
}
// 2. A named relationship (FK) whose child is this table?
let rel_count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1 AND child_table = ?2;"),
[name, table],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if rel_count > 0 {
return do_drop_relationship(
conn,
persistence,
source,
&RelationshipSelector::Named {
name: name.to_string(),
},
);
}
// 3. A composite UNIQUE whose derived name (ADR-0035 Amendment 1,
// `unique_<cols>`) matches? The constraint is anonymous in our
// model, so we recompute each composite UNIQUE's name and match.
// Order matters: a named CHECK/FK above shadows a derived UNIQUE
// name (the distinctive `unique_` prefix makes a clash unlikely).
let schema = read_schema(conn, table)?;
let matched_cols: Vec<Vec<String>> = schema
.unique_constraints
.iter()
.filter(|cols| unique_constraint_name(cols) == name)
.cloned()
.collect();
if matched_cols.len() > 1 {
// Two distinct UNIQUEs can derive the same name (e.g. a column
// literally named `b_c` vs `UNIQUE (b, c)`). Refuse rather than
// guess which to drop.
return Err(DbError::Unsupported(format!(
"the constraint name `{name}` is ambiguous on `{table}` — it \
matches more than one UNIQUE constraint; recreate the table \
to change them."
)));
}
if let Some(cols) = matched_cols.first() {
let old_schema = schema.clone();
let mut new_schema = schema;
new_schema.unique_constraints.retain(|c| c != cols);
let table_owned = table.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
return Ok(Some(do_describe_table(conn, table)?));
}
// 4. Not a known named constraint on this table.
Err(DbError::Sqlite {
message: format!("no such constraint: {name} on {table}"),
kind: SqliteErrorKind::Other,
})
}
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (<col>)
/// REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Resolves a bare
/// `REFERENCES <P>` to the parent's single-column PK, then delegates to
/// `do_add_relationship` (the same machinery `add 1:n relationship`
/// uses) with `create_fk = false` — the child column must already exist
/// (an `ALTER … ADD FOREIGN KEY` references an existing column).
fn do_alter_add_foreign_key(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
child_table: &str,
name: Option<&str>,
fk: &SqlForeignKey,
) -> Result<TableDescription, DbError> {
debug!(child = %child_table, name = ?name, "alter_add_foreign_key");
reject_internal_table_name(child_table)?;
reject_internal_table_name(&fk.parent_table)?;
// Resolve the parent columns: explicit must be the full PK (F-A);
// a bare `REFERENCES P` auto-expands to the full PK in PK order
// (F-D), arity-matched to the child list (ADR-0043).
let parent_pk = read_schema(conn, &fk.parent_table)?.primary_key;
let parent_columns = resolve_fk_parent_columns(
&fk.parent_table,
&parent_pk,
fk.parent_columns.as_deref(),
fk.child_columns.len(),
fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level)
)?;
// Every child column must already exist for `ALTER … ADD FOREIGN
// KEY` — there is no SQL spelling to auto-create one (`--create-fk`
// is the simple-mode `add relationship` surface only). Pre-check
// here so the refusal speaks SQL, not the DSL flag (ADR-0035
// Amendment 1, gap C). A missing child *table* is left to
// `do_add_relationship`'s own "no such table".
if let Ok(child_schema) = read_schema(conn, child_table) {
let missing: Vec<&String> = fk
.child_columns
.iter()
.filter(|c| child_schema.columns.iter().all(|col| &col.name != *c))
.collect();
if let Some(child) = missing.first() {
return Err(DbError::Unsupported(format!(
"column `{child_table}.{child}` does not exist — add it first \
(`alter table {child_table} add column {child} <type>`), then \
add the foreign key."
)));
}
}
do_add_relationship(
conn,
persistence,
source,
name,
&fk.parent_table,
&parent_columns,
child_table,
&fk.child_columns,
fk.on_delete,
fk.on_update,
false,
)
}
/// Create an index on `table` over `columns` (ADR-0025).
///
/// Refuses a redundant index on an already-indexed column set
/// and a name collision. The index name is auto-generated as
/// `<table>_<col…>_idx` when not supplied.
/// Resolve an index name: the user-given name, or the ADR-0025
/// auto-name `<table>_<col…>_idx`. Shared by `do_add_index` and the
/// `CREATE INDEX IF NOT EXISTS` skip pre-check (ADR-0035 §4d) so both
/// compute the same name.
fn resolve_index_name(name: Option<&str>, table: &str, columns: &[String]) -> String {
name.map_or_else(
|| format!("{table}_{}_idx", columns.join("_")),
ToString::to_string,
)
}
/// Refuse an internal `__rdbms_*` table as "no such table" — the same
/// opacity the rest of the app presents (internal tables are filtered
/// from `list_tables` and never offered in completion). Guards the
/// user-facing schema-mutation executors so a deliberately-typed
/// internal name cannot index or alter the metadata tables (ADR-0035
/// §4d/§4e; the grammar's `reject_internal_table` covers only the typed
/// SQL family, not the simple DSL nodes).
fn reject_internal_table_name(table: &str) -> Result<(), DbError> {
if table.to_ascii_lowercase().starts_with("__rdbms_") {
return Err(DbError::Sqlite {
message: format!("no such table: {table}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
Ok(())
}
/// Whether an index named `name` exists (ADR-0035 §4d skip checks).
///
/// `user_only = true` counts only explicit `CREATE INDEX` objects
/// (`sql IS NOT NULL`), matching `do_drop_index`'s named lookup — used
/// by the `DROP INDEX IF EXISTS` skip. `user_only = false` counts any
/// index of that name (incl. the automatic PK / UNIQUE-constraint
/// indexes), matching `do_add_index`'s name-collision guard — used by
/// the `CREATE INDEX IF NOT EXISTS` skip.
fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result<bool, DbError> {
let sql = if user_only {
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;"
} else {
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1;"
};
let count: i64 = conn
.query_row(sql, [name], |row| row.get(0))
.map_err(DbError::from_rusqlite)?;
Ok(count > 0)
}
fn do_add_index(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
name: Option<&str>,
table: &str,
columns: &[String],
unique: bool,
) -> Result<TableDescription, DbError> {
debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index");
// 0. Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table) — both the simple `add index` and SQL
// `CREATE INDEX` surfaces reach here, and the auto-index name embeds
// the table name, so it must use the stored case.
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
// 1. Table must exist; gather its columns.
let schema = read_schema(conn, table)?;
// 2. Every indexed column must exist on the table.
for col in columns {
if !schema.columns.iter().any(|c| &c.name == col) {
return Err(DbError::Sqlite {
message: format!("no such column: {table}.{col}"),
kind: SqliteErrorKind::NoSuchColumn,
});
}
}
// 3. Refuse a redundant index over an identical column set *of the
// same kind*. A plain and a unique index over the same columns are
// NOT redundant (the unique one enforces a constraint the plain one
// does not), so the guard keys on `(columns, unique)` (ADR-0035
// §4d). To hold both, the user must name them distinctly — the
// auto-name is identical, so the name guard (step 5) would
// otherwise collide.
let existing = read_table_indexes(conn, table)?;
if let Some(dup) = existing
.iter()
.find(|i| i.columns.as_slice() == columns && i.unique == unique)
{
return Err(DbError::Unsupported(format!(
"the columns ({}) of `{table}` are already indexed by `{}`.",
columns.join(", "),
dup.name,
)));
}
// 4. Resolve the index name (auto-generate when omitted).
let resolved = resolve_index_name(name, table, columns);
// 5. Refuse a name collision.
let name_taken: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'index' AND name = ?1;",
[&resolved],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
if name_taken > 0 {
return Err(DbError::Unsupported(format!(
"an index named `{resolved}` already exists. \
Pick a different name or drop the existing one first."
)));
}
// 6. Create the index and persist.
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let cols_csv = columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let unique_kw = if unique { "UNIQUE " } else { "" };
let ddl = format!(
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&resolved),
tbl = quote_ident(table),
cols = cols_csv,
);
debug!(ddl = %ddl, unique, "add_index");
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
let description = do_describe_table(conn, table)?;
let changes = Changes {
schema_dirty: true,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(description)
}
/// Drop an index identified by name or by table + column set
/// (ADR-0025). Returns the affected table's description.
fn do_drop_index(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
selector: &IndexSelector,
) -> Result<TableDescription, DbError> {
debug!(selector = ?selector, "drop_index");
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> {
debug!(name = %name, "describe_table");
// Column info — including the ADR-0029 constraints — comes
// from `read_schema`, the single source of per-column truth
// (it joins `pragma_table_info` with our type metadata and
// detects single-column UNIQUE via `pragma_index_list`). A
// missing table surfaces as `read_schema`'s NoSuchTable.
let schema = read_schema(conn, name)?;
let columns: Vec<ColumnDescription> = schema
.columns
.iter()
.map(|c| ColumnDescription {
name: c.name.clone(),
user_type: c.user_type,
sqlite_type: c.sqlite_type.clone(),
notnull: c.notnull,
primary_key: c.primary_key,
unique: c.unique,
default: c.default_sql.clone(),
check: c.check.clone(),
})
.collect();
let outbound_relationships = read_relationships_outbound(conn, name)?;
let inbound_relationships = read_relationships_inbound(conn, name)?;
let indexes = read_table_indexes(conn, name)?;
Ok(TableDescription {
name: name.to_string(),
columns,
outbound_relationships,
inbound_relationships,
indexes,
unique_constraints: schema.unique_constraints.clone(),
check_constraints: schema.check_constraints,
})
}
// --- Data operations (C5) -----------------------------------
fn impl_value_for(
schema: &ReadSchema,
column: &str,
value: &Value,
) -> Result<Bound, DbError> {
let col = schema
.columns
.iter()
.find(|c| c.name == column)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {column}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let ty = col.user_type.ok_or_else(|| {
DbError::Unsupported(format!(
"column `{column}` has no user-type metadata; cannot validate value"
))
})?;
value
.bind_for_column(column, ty)
.map_err(|e: ValueError| DbError::InvalidValue(e.to_string()))
}
fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value {
use rusqlite::types::Value as V;
match b {
Bound::Integer(i) => V::Integer(*i),
Bound::Real(r) => V::Real(*r),
Bound::Text(s) => V::Text(s.clone()),
Bound::Null => V::Null,
}
}
// =================================================================
// WHERE-expression → parameterised SQL (ADR-0026 §6)
// =================================================================
/// Compile an `Expr` to a parameterised SQL boolean expression.
///
/// Every literal becomes a `?` placeholder pushed onto `params`
/// (1-based, continuing from the current `params.len()` — so
/// the caller can pre-load SET-clause params); identifiers are
/// `quote_ident`-quoted. The raw user text is never spliced
/// into the SQL. Connectives, `NOT`, and parentheses come from
/// the tree structure; the database re-derives precedence from
/// the emitted operators.
fn compile_expr(
expr: &Expr,
schema: &ReadSchema,
params: &mut Vec<rusqlite::types::Value>,
) -> String {
match expr {
Expr::Or(terms) => join_expr(terms, "OR", schema, params),
Expr::And(terms) => join_expr(terms, "AND", schema, params),
Expr::Not(inner) => {
format!("(NOT {})", compile_expr(inner, schema, params))
}
Expr::Predicate(predicate) => compile_predicate(predicate, schema, params),
}
}
fn join_expr(
terms: &[Expr],
op: &str,
schema: &ReadSchema,
params: &mut Vec<rusqlite::types::Value>,
) -> String {
let parts: Vec<String> = terms
.iter()
.map(|t| compile_expr(t, schema, params))
.collect();
format!("({})", parts.join(&format!(" {op} ")))
}
fn compile_predicate(
predicate: &Predicate,
schema: &ReadSchema,
params: &mut Vec<rusqlite::types::Value>,
) -> String {
match predicate {
Predicate::Compare { left, op, right } => {
// A literal on one side binds against the column on
// the other side, where there is one.
let left_ty = operand_column_type(left, schema);
let right_ty = operand_column_type(right, schema);
let lhs = compile_operand(left, right_ty, params);
let rhs = compile_operand(right, left_ty, params);
format!("{lhs} {} {rhs}", compare_op_sql(*op))
}
Predicate::Like {
target,
pattern,
negated,
} => {
let t = compile_operand(target, None, params);
let p = compile_operand(pattern, None, params);
let not = if *negated { "NOT " } else { "" };
format!("{t} {not}LIKE {p}")
}
Predicate::Between {
target,
low,
high,
negated,
} => {
let ty = operand_column_type(target, schema);
let t = compile_operand(target, None, params);
let lo = compile_operand(low, ty, params);
let hi = compile_operand(high, ty, params);
let not = if *negated { "NOT " } else { "" };
format!("{t} {not}BETWEEN {lo} AND {hi}")
}
Predicate::In {
target,
items,
negated,
} => {
let ty = operand_column_type(target, schema);
let t = compile_operand(target, None, params);
let rendered: Vec<String> = items
.iter()
.map(|item| compile_operand(item, ty, params))
.collect();
let not = if *negated { "NOT " } else { "" };
format!("{t} {not}IN ({})", rendered.join(", "))
}
Predicate::IsNull { target, negated } => {
let t = compile_operand(target, None, params);
let not = if *negated { "NOT " } else { "" };
format!("{t} IS {not}NULL")
}
}
}
/// Render an operand. A column becomes a quoted identifier; a
/// literal becomes a `?` placeholder bound against `against`'s
/// type when one is supplied.
fn compile_operand(
operand: &Operand,
against: Option<Type>,
params: &mut Vec<rusqlite::types::Value>,
) -> String {
match operand {
Operand::Column { name, .. } => quote_ident(name),
Operand::Literal { value, .. } => {
params.push(bind_where_literal(value, against));
format!("?{}", params.len())
}
}
}
/// The user-facing type of a column operand, if the operand is
/// a column the schema knows. Literals and unknown columns
/// yield `None`.
fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option<Type> {
match operand {
Operand::Column { name, .. } => schema
.columns
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.and_then(|c| c.user_type),
Operand::Literal { .. } => None,
}
}
/// Bind a WHERE-clause literal. When a target column type is
/// known and the literal converts cleanly, bind through it;
/// otherwise bind by the literal's own syntactic shape — a
/// type-mismatched comparison is flagged in the editor but
/// still runs (ADR-0026 §7).
fn bind_where_literal(value: &Value, against: Option<Type>) -> rusqlite::types::Value {
if let Some(ty) = against
&& let Ok(bound) = value.bind_for_column("", ty)
{
return bound_to_sqlite_value(&bound);
}
bound_to_sqlite_value(&syntactic_bound(value))
}
/// Bind a literal by the shape it was written in, ignoring any
/// column type — the permissive fallback for type-mismatched
/// comparisons and the literal-vs-literal case.
fn syntactic_bound(value: &Value) -> Bound {
match value {
Value::Null => Bound::Null,
Value::Bool(b) => Bound::Integer(i64::from(*b)),
Value::Text(s) => Bound::Text(s.clone()),
Value::Number(s) => s.parse::<i64>().map_or_else(
|_| {
s.parse::<f64>()
.map_or_else(|_| Bound::Text(s.clone()), Bound::Real)
},
Bound::Integer,
),
}
}
const fn compare_op_sql(op: CompareOp) -> &'static str {
match op {
CompareOp::Eq => "=",
// `<>` is standard SQL for inequality (ADR-0026 §6).
CompareOp::NotEq => "<>",
CompareOp::Lt => "<",
CompareOp::LtEq => "<=",
CompareOp::Gt => ">",
CompareOp::GtEq => ">=",
}
}
/// Execute an INSERT/UPDATE/DELETE and convert any rusqlite
/// failure into a `DbError`. Wraps the raw `conn.execute` so the
/// three callers (insert, update, delete) have a single hook for
/// future row-pinpointing per ADR-0019 §6 — when re-query lands,
/// the runtime-side wiring will pass the table identity into the
/// translator from here.
///
/// Today this is a thin wrapper. The `table` argument is
/// retained as the future re-query hook needs it. (The previous
/// `enrich_fk_message` helper that lived here, listing all the
/// outbound/inbound FKs in the error message, was absorbed into
/// the friendly-error layer's catalog wording per ADR-0019;
/// re-introducing the per-FK detail belongs to the re-query
/// follow-on, not as freeform plain-text appended to engine
/// errors.)
fn execute_with_fk_enrichment(
conn: &Connection,
_table: &str,
sql: &str,
params: &[rusqlite::types::Value],
) -> Result<usize, DbError> {
conn.execute(sql, rusqlite::params_from_iter(params.iter()))
.map_err(DbError::from_rusqlite)
}
/// Fetch a small `DataResult` containing only the rows whose
/// rowids appear in `rowids`. Used so writes can show only the
/// rows they touched rather than the whole table.
fn query_rows_by_rowid(
conn: &Connection,
table: &str,
rowids: &[i64],
) -> Result<DataResult, DbError> {
let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
schema.columns.iter().map(|c| c.user_type).collect();
if rowids.is_empty() {
return Ok(DataResult {
table_name: table.to_string(),
columns: column_names,
column_types,
rows: Vec::new(),
});
}
let cols_csv = column_names
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let placeholders = (1..=rowids.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT {cols} FROM {ident} WHERE rowid IN ({placeholders});",
cols = cols_csv,
ident = quote_ident(table),
);
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let params: Vec<rusqlite::types::Value> = rowids
.iter()
.map(|id| rusqlite::types::Value::Integer(*id))
.collect();
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let mut cells: Vec<rusqlite::types::Value> =
Vec::with_capacity(column_names.len());
for i in 0..column_names.len() {
cells.push(row.get(i)?);
}
Ok(cells)
})
.map_err(DbError::from_rusqlite)?;
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
let formatted: Vec<Option<String>> = cells
.into_iter()
.zip(column_types.iter())
.map(|(v, ty)| format_cell(v, *ty))
.collect();
rows.push(formatted);
}
Ok(DataResult {
table_name: table.to_string(),
columns: column_names,
column_types,
rows,
})
}
fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
let sql = format!("SELECT COUNT(*) FROM {ident};", ident = quote_ident(table));
conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
.map_err(DbError::from_rusqlite)
}
fn do_insert(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
user_columns: Option<&[String]>,
user_values: &[Value],
) -> Result<InsertResult, DbError> {
debug!(table = %table, "insert");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Resolve which columns the user is providing values for.
let user_cols: Vec<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(),
));
}
debug!(
table = %table,
user_cols = user_cols.len(),
total_cols = bindings.len(),
autofilled = bindings.len() - user_cols.len(),
"insert: column bindings resolved (serial/shortid auto-fill applied)"
);
let cols_csv = bindings
.iter()
.map(|(c, _)| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let placeholders = (1..=bindings.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
ident = quote_ident(table),
);
debug!(sql = %sql, "insert");
let params: Vec<rusqlite::types::Value> =
bindings.iter().map(|(_, b)| bound_to_sqlite_value(b)).collect();
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;
let new_rowid = conn.last_insert_rowid();
let data = query_rows_by_rowid(conn, table, &[new_rowid])?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(InsertResult {
rows_affected,
data,
})
}
/// Build the parameterised `UPDATE … SET … WHERE …` statement.
/// Separated from `do_update` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2). `compile_expr` continues the `?N` numbering
/// from the SET-clause params already pushed.
fn build_update_sql(
schema: &ReadSchema,
table: &str,
assignments: &[(String, Value)],
filter: &RowFilter,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
for (col, value) in assignments {
let bound = impl_value_for(schema, col, value)?;
set_clauses.push(format!(
"{col_id} = ?{n}",
col_id = quote_ident(col),
n = params.len() + 1
));
params.push(bound_to_sqlite_value(&bound));
}
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
}
};
let sql = format!(
"UPDATE {ident} SET {sets}{where_sql};",
ident = quote_ident(table),
sets = set_clauses.join(", "),
);
Ok((sql, params))
}
fn do_update(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
assignments: &[(String, Value)],
filter: &RowFilter,
) -> Result<UpdateResult, DbError> {
debug!(table = %table, assignments = assignments.len(), "update");
if assignments.is_empty() {
return Err(DbError::InvalidValue(
"UPDATE requires at least one assignment".to_string(),
));
}
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Capture rowids of matching rows up front so we can fetch
// the updated rows even if the UPDATE changed the WHERE column.
let rowids = match filter {
RowFilter::AllRows => select_all_rowids(conn, table)?,
RowFilter::Where(expr) => {
let mut where_params: Vec<rusqlite::types::Value> = Vec::new();
let clause = compile_expr(expr, &schema, &mut where_params);
let mut stmt = conn
.prepare(&format!(
"SELECT rowid FROM {ident} WHERE {clause};",
ident = quote_ident(table),
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(where_params.iter()), |row| {
row.get::<_, i64>(0)
})
.map_err(DbError::from_rusqlite)?;
let mut ids = Vec::new();
for r in rows {
ids.push(r.map_err(DbError::from_rusqlite)?);
}
ids
}
};
let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
debug!(sql = %sql, "update");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;
let data = query_rows_by_rowid(conn, table, &rowids)?;
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(UpdateResult {
rows_affected,
data,
})
}
fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError> {
let mut stmt = conn
.prepare(&format!(
"SELECT rowid FROM {ident};",
ident = quote_ident(table)
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))
.map_err(DbError::from_rusqlite)?;
let mut ids = Vec::new();
for r in rows {
ids.push(r.map_err(DbError::from_rusqlite)?);
}
Ok(ids)
}
/// Build the parameterised `DELETE FROM … WHERE …` statement.
/// Separated from `do_delete` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2).
fn build_delete_sql(
schema: &ReadSchema,
table: &str,
filter: &RowFilter,
) -> (String, Vec<rusqlite::types::Value>) {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
}
};
let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table));
(sql, params)
}
fn do_delete(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
filter: &RowFilter,
) -> Result<DeleteResult, DbError> {
debug!(table = %table, "delete");
let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str();
let schema = read_schema(conn, table)?;
// Snapshot child-table row counts before the delete so we
// can detect cascade effects via diffing afterwards. ON
// UPDATE CASCADE does not change row counts and so is not
// detected here — it would need value-level diffing, which
// a future iteration can add.
let inbound = read_relationships_inbound(conn, table)?;
let mut before_counts: Vec<(String, i64)> = Vec::with_capacity(inbound.len());
for r in &inbound {
before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
}
let (sql, params) = build_delete_sql(&schema, table, filter);
debug!(sql = %sql, "delete");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, &params)?;
// Compare child-table counts after the delete; non-zero
// diffs are cascade effects. We also collect cascaded
// tables so the persistence phase rewrites their CSVs too.
let mut cascade: Vec<CascadeEffect> = Vec::new();
let mut rewritten_tables: Vec<String> = vec![table.to_string()];
for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) {
let after_count = count_rows(conn, &rel.other_table)?;
let mut rows_changed = before_count - after_count;
// A self-referential FK (child == target): the before/after
// diff also covers the directly-deleted rows, which are
// already reported in `rows_affected` and are not cascade
// effects. Subtract them so the summary reports only the rows
// removed *via* the self-reference. Shared with `do_sql_delete`.
let self_referential = rel.other_table == table;
if self_referential {
rows_changed -= rows_affected as i64;
}
if rows_changed > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed,
action: rel.on_delete,
});
// The target's CSV is already queued; only add a distinct
// child table (a self-ref child is the target itself).
if !self_referential {
rewritten_tables.push(rel.other_table.clone());
}
}
}
debug!(
table = %table,
rows_affected,
cascaded_relationships = cascade.len(),
rewritten_tables = rewritten_tables.len(),
"delete: complete (cascade effects detected by child-count diff)"
);
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
// The DSL `delete` has no RETURNING (a SQL-only clause); the
// empty result is skipped by the renderer (ADR-0033 §5, 3g).
data: DataResult {
table_name: table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
})
}
/// Read-only wrapper that adds the `history.log` append for
/// `show data` user commands.
fn do_query_data_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> Result<DataResult, DbError> {
let data = do_query_data(conn, table, filter, limit)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
}
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
/// ADR-0031). Mirrors `do_query_data_request`: run the
/// statement, append the literal line to `history.log` so a
/// replay re-runs it (ADR-0030 §11).
fn do_run_select_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
) -> Result<DataResult, DbError> {
let data = do_run_select(conn, sql)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
}
/// Currently-stored non-NULL values of one column, for shortid
/// collision-avoidance (passed to `generate_shortid_batch`).
fn existing_shortids(
conn: &Connection,
table: &str,
column: &str,
) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(&format!(
"SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;",
col = quote_ident(column),
tbl = quote_ident(table),
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6,
/// sub-phase 3d).
///
/// Returns the SQL the worker should execute plus its bound
/// params. When the user's `(column_list)` omits one or more
/// `shortid` columns, this materialises the row source (Option B:
/// run it as a query), synthesises a fresh distinct id per row via
/// `generate_shortid_batch`, and reconstructs a parameterised
/// multi-row `INSERT` over the listed columns plus the omitted
/// shortid columns. Otherwise it returns the original `sql`
/// verbatim with no params (the 3b path):
///
/// - no explicit column list → the row source supplies every
/// column positionally (a listed shortid is the user's value);
/// - no omitted shortid column → nothing to fill;
/// - the row source yields zero rows → nothing to fill (the
/// verbatim INSERT inserts nothing without a NOT-NULL violation).
///
/// `serial` columns are not handled here — an omitted `serial`
/// primary key is filled by the engine's rowid (ADR-0033 §6).
/// Auto-fill the omitted **auto-generated** columns of an advanced-mode
/// Form-A `INSERT` (a `(col, …)` list is present) by reconstructing a
/// parameterised statement, honouring ADR-0018 §1's "auto-generated on
/// every path" contract — the same contract simple-mode `do_insert`
/// honours:
/// - **`shortid`** columns omitted from the list get a fresh distinct
/// id per row (ADR-0018 §3).
/// - **non-PK `serial`** columns omitted from the list get `MAX(col)+1`,
/// `MAX+2`, … per row (ADR-0018 §5; the X4 fix — advanced mode used
/// to leave them NULL). PK serial relies on the engine's rowid alias
/// and is never in this set.
///
/// Returns the original `sql` + an empty param vec when there is nothing
/// to fill (Form B / no omitted auto-gen column / zero rows), so the
/// caller executes the verbatim statement unchanged.
fn plan_autogen_autofill(
conn: &Connection,
target_table: &str,
sql: &str,
listed_columns: &[String],
row_source: &str,
trailing_tail: &str,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
if listed_columns.is_empty() {
return Ok((sql.to_string(), Vec::new()));
}
let schema = read_schema(conn, target_table)?;
// Identifiers are case-preserving but matched case-insensitively
// (ADR-0009): a column counts as omitted unless the user listed a
// name equal to it ignoring ASCII case.
let listed_ci: Vec<String> =
listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect();
let is_omitted = |c: &&ReadColumn| !listed_ci.contains(&c.name.to_ascii_lowercase());
let omitted_shortids: Vec<String> = schema
.columns
.iter()
.filter(|c| c.user_type == Some(Type::ShortId))
.filter(is_omitted)
.map(|c| c.name.clone())
.collect();
// ADR-0018 §5 + X4: a non-PK serial omitted from the list is
// auto-filled MAX+1 per row (PK serial uses the rowid alias and is
// excluded). Mirrors simple-mode `do_insert`.
let omitted_serials: Vec<String> = schema
.columns
.iter()
.filter(|c| c.user_type == Some(Type::Serial) && !c.primary_key)
.filter(is_omitted)
.map(|c| c.name.clone())
.collect();
if omitted_shortids.is_empty() && omitted_serials.is_empty() {
return Ok((sql.to_string(), Vec::new()));
}
// Materialise the row source (VALUES / SELECT / WITH … SELECT)
// as concrete rows for the listed columns.
let listed_count = listed_columns.len();
let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?;
// Arity guard: if the row source's column count disagrees with
// the user's column list, do NOT auto-fill — reading
// `listed_count` cells would silently drop extra columns (or
// error opaquely on too few). Defer to the verbatim statement
// so the engine reports the mismatch as it does on the
// non-auto-fill path (a friendly pre-flight lands in 3i).
if stmt.column_count() != listed_count {
return Ok((sql.to_string(), Vec::new()));
}
let mut rows: Vec<Vec<rusqlite::types::Value>> = Vec::new();
{
let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?;
while let Some(r) = q.next().map_err(DbError::from_rusqlite)? {
let mut cells = Vec::with_capacity(listed_count);
for i in 0..listed_count {
cells.push(
r.get::<_, rusqlite::types::Value>(i)
.map_err(DbError::from_rusqlite)?,
);
}
rows.push(cells);
}
}
let n = rows.len();
if n == 0 {
// Nothing to insert — the verbatim statement inserts zero
// rows without touching the omitted shortid column.
return Ok((sql.to_string(), Vec::new()));
}
// A fresh, distinct shortid per row for each omitted column,
// avoiding collision with values already stored in that column.
let mut id_batches: Vec<Vec<rusqlite::types::Value>> =
Vec::with_capacity(omitted_shortids.len());
for col in &omitted_shortids {
let existing = existing_shortids(conn, target_table, col)?;
id_batches.push(generate_shortid_batch(n, &existing)?);
}
// A serial sequence per omitted non-PK serial column: MAX(col)+1 …
// MAX(col)+n. MAX is read once (current table state); the worker-
// thread serialisation (ADR-0010) makes the read-then-insert safe.
// All values exceed the current MAX, so they collide with neither
// existing rows nor each other (the column's UNIQUE holds). MAX+1
// (gaps jumped, not back-filled) matches `do_insert` and the rowid
// PK case (ADR-0018 §5 / Resolution 2).
let mut serial_batches: Vec<Vec<rusqlite::types::Value>> =
Vec::with_capacity(omitted_serials.len());
for col in &omitted_serials {
let max: i64 = conn
.query_row(
&format!(
"SELECT COALESCE(MAX({col}), 0) FROM {tbl};",
col = quote_ident(col),
tbl = quote_ident(target_table),
),
[],
|row| row.get(0),
)
.map_err(DbError::from_rusqlite)?;
serial_batches
.push((1..=n as i64).map(|i| rusqlite::types::Value::Integer(max + i)).collect());
}
// Reconstruct: listed columns, then the omitted shortid columns,
// then the omitted non-PK serial columns; one parameterised tuple
// per materialised row. The param push order below matches this.
let all_cols: Vec<&String> = listed_columns
.iter()
.chain(omitted_shortids.iter())
.chain(omitted_serials.iter())
.collect();
let cols_csv = all_cols
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let per_tuple = all_cols.len();
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(n * per_tuple);
let mut tuples: Vec<String> = Vec::with_capacity(n);
let mut ph = 1;
for (row_idx, row) in rows.into_iter().enumerate() {
for cell in row {
params.push(cell);
}
for batch in &id_batches {
params.push(batch[row_idx].clone());
}
for batch in &serial_batches {
params.push(batch[row_idx].clone());
}
let placeholders = (0..per_tuple)
.map(|_| {
let s = format!("?{ph}");
ph += 1;
s
})
.collect::<Vec<_>>()
.join(", ");
tuples.push(format!("({placeholders})"));
}
// Preserve any trailing clause — `ON CONFLICT …` (3h) and/or
// `RETURNING …` (3g). The reconstruction rebuilds only INSERT …
// VALUES …, so without this the UPSERT action would be lost and
// `RETURNING *` would yield no rows. `trailing_tail` is "" when
// the statement has neither.
let trailing_suffix = if trailing_tail.is_empty() {
String::new()
} else {
format!(" {trailing_tail}")
};
let exec_sql = format!(
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals}{trailing_suffix};",
tbl = quote_ident(target_table),
vals = tuples.join(", "),
);
Ok((exec_sql, params))
}
/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1,
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
/// run the validated SQL inside a transaction, re-persist the
/// target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()` (so a
/// persistence failure rolls the insert back), then commit.
///
/// Grammar-as-text (ADR-0030 §4): normally the values are literals
/// in `sql` and no parameters are bound. The auto-fill path (an
/// omitted `shortid` or non-PK `serial` column) is the exception — it
/// reconstructs a parameterised `INSERT` (see `plan_autogen_autofill`);
/// either way `history.log` records the original `source`, never the
/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL
/// engine errors surface enriched via `execute_with_fk_enrichment`
/// + the friendly-error layer.
///
/// Auto-show is best-effort: the inserted rows are the last
/// `rows_affected` rowids ending at `last_insert_rowid()`. For the
/// common case (sequential / engine-assigned rowids) this is
/// exactly the inserted rows; an INSERT that sets explicit
/// non-contiguous rowid/INTEGER-PK values may surface a partial
/// view. `RETURNING` (sub-phase 3g) is the precise tool.
#[allow(clippy::too_many_arguments)]
fn do_sql_insert(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
listed_columns: &[String],
row_source: &str,
returning: bool,
literal_rows: &[Vec<Option<Value>>],
) -> Result<InsertResult, DbError> {
debug!(table = %target_table, returning, "sql_insert");
debug!(sql = %sql, table = %target_table, returning, "sql_insert");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// ADR-0036 Phase 1: validate captured literal VALUES against their
// column types BEFORE the (still verbatim) insert runs — sharing the
// DSL's per-type validators (`impl_value_for`) for identical wording.
// Only literal positions are checked; expression positions (`None`)
// are left to the engine. Column mapping mirrors the engine's: an
// explicit column list maps by position; natural order maps to the
// schema's columns in definition order. An out-of-range position
// (arity mismatch) is left for the engine / parse-time diagnostic.
// Execution below is unchanged (no binding, no auto-fill change).
if literal_rows.iter().any(|r| r.iter().any(Option::is_some)) {
let schema = read_schema(conn, target_table)?;
let columns: Vec<&str> = if listed_columns.is_empty() {
schema.columns.iter().map(|c| c.name.as_str()).collect()
} else {
listed_columns.iter().map(String::as_str).collect()
};
for row in literal_rows {
for (idx, slot) in row.iter().enumerate() {
if let Some(value) = slot
&& let Some(col) = columns.get(idx)
{
impl_value_for(&schema, col, value)?;
}
}
}
}
// The `shortid` auto-fill rewrite reconstructs only `INSERT …
// VALUES …` and would drop any trailing clause — `ON CONFLICT …`
// (3h) and/or `RETURNING …` (3g). `row_source` is the clean
// VALUES/SELECT text (`build_sql_insert` stops the slice at the
// first trailing clause), so whatever follows it in the full
// `sql` is exactly that tail; extract it here so the rewrite can
// re-append it verbatim. On the verbatim (no auto-fill) path the
// original `sql` already carries the tail, so it is consumed only
// by the rewrite.
let trailing_tail: String = if row_source.is_empty() {
String::new()
} else {
sql.find(row_source)
.map(|i| sql[i + row_source.len()..].trim().trim_end_matches(';').trim().to_string())
.unwrap_or_default()
};
// Sub-phase 3d: when the user's column list omits one or more
// `shortid` columns, the worker materialises the row source,
// synthesises fresh distinct ids, and reinserts the augmented
// rows. Returns the executable SQL + bound params; an empty
// params vec with the original `sql` means "no auto-fill —
// execute verbatim" (the 3b path).
let (exec_sql, params) =
plan_autogen_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?;
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// RETURNING (3g): one pass inserts and yields the inserted rows
// (incl. any auto-filled shortid), so the returned set is the
// precise auto-show and rows_affected is its length. Without
// RETURNING, fall back to the best-effort rowid auto-show.
let (rows_affected, data) = if returning {
let data = run_returning(conn, &exec_sql, &params, target_table)?;
(data.rows.len(), data)
} else {
let n = execute_with_fk_enrichment(conn, target_table, &exec_sql, &params)?;
let last = conn.last_insert_rowid();
let rowids: Vec<i64> = if n == 0 {
Vec::new()
} else {
let count = n as i64;
((last - count + 1)..=last).collect()
};
let data = query_rows_by_rowid(conn, target_table, &rowids)?;
(n, data)
};
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(InsertResult {
rows_affected,
data,
})
}
/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2,
/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence
/// discipline: run the validated SQL inside a transaction,
/// re-persist the target table's CSV + append `history.log` via
/// `finalize_persistence` *before* `tx.commit()`, then commit.
///
/// Grammar-as-text (ADR-0030 §4): the assignment and predicate
/// values are literals in `sql`, so no parameters are bound. A SQL
/// `UPDATE` without `WHERE` runs across all rows as written
/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero
/// rows is a success (`rows_affected == 0`); the persistence
/// write-through still runs (re-persisting the unchanged CSV is a
/// no-op-equivalent and keeps the path uniform).
///
/// Auto-show: 3e returns an empty [`DataResult`] — the affected
/// rows can't be shown precisely without `RETURNING` (sub-phase
/// 3g, which is the precise tool). The summary surfaces the
/// affected-row count; the renderer skips the (column-less) table.
fn do_sql_update(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
returning: bool,
set_literals: &[(String, Option<Value>)],
) -> Result<UpdateResult, DbError> {
debug!(table = %target_table, returning, "sql_update");
debug!(sql = %sql, table = %target_table, returning, "sql_update");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// ADR-0036 Phase 2: validate each captured `SET col = <literal>`
// against its column type BEFORE the (still verbatim) update runs —
// sharing the DSL's per-type validators (`impl_value_for`, the same
// helper `build_update_sql` uses) for identical wording. Only literal
// assignments are checked; expression positions (`None`) and the
// `WHERE` predicate are left to the engine (ADR-0036 §2). Execution
// below is unchanged (no binding).
if set_literals.iter().any(|(_, v)| v.is_some()) {
let schema = read_schema(conn, target_table)?;
for (col, slot) in set_literals {
if let Some(value) = slot {
impl_value_for(&schema, col, value)?;
}
}
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// RETURNING (3g): one pass performs the update and yields the
// modified rows; rows_affected is the row count. Without
// RETURNING the affected-row count surfaces and the (column-less)
// DataResult is skipped by the renderer (3e behaviour).
let (rows_affected, data) = if returning {
let data = run_returning(conn, sql, &[], target_table)?;
(data.rows.len(), data)
} else {
let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
(
n,
DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
)
};
let changes = Changes {
schema_dirty: false,
rewritten_tables: vec![target_table.to_string()],
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(UpdateResult { rows_affected, data })
}
/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
/// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing
/// only in that it executes the verbatim grammar-validated `sql`
/// rather than building the statement from a typed filter.
///
/// Cascade detection (ADR-0033 Amendment 2): the worker snapshots
/// each inbound child table's row count *before* the DELETE, runs
/// the statement inside a transaction (the engine applies any
/// `ON DELETE CASCADE`), counts the children again *after*, and
/// reports the positive difference as a [`CascadeEffect`]. This is
/// the identical mechanism the DSL path uses, so the SQL and DSL
/// DELETE produce the same per-relationship summary on the same
/// schema/data — and since both return a [`DeleteResult`] routed
/// through `CommandOutcome::Delete`, the render-layer formatter is
/// shared with no duplication. `ON DELETE SET NULL` leaves row
/// counts unchanged and so is not reported on either path (a
/// deferred enhancement for both).
///
/// Because the diff observes the result of executing the whole
/// statement, the WHERE clause is never inspected — a WHERE that
/// itself contains a subquery (the R2 invariant) is correct by
/// construction and carries no extra per-child query cost.
///
/// Persistence discipline matches `do_delete` / `do_sql_update`:
/// re-persist the target's CSV *and every cascade-affected child's*
/// CSV via `finalize_persistence` (which also appends `source` to
/// `history.log`) *before* `tx.commit()`, so a persistence failure
/// rolls the delete back. A DELETE matching zero rows is a success
/// (`rows_affected == 0`, empty cascade); the target's CSV is still
/// re-persisted, keeping the path uniform.
fn do_sql_delete(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
sql: &str,
target_table: &str,
returning: bool,
) -> Result<DeleteResult, DbError> {
debug!(sql = %sql, table = %target_table, returning, "sql_delete");
let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str();
// Snapshot child-table row counts before the delete so cascade
// effects can be detected by diffing afterwards (Amendment 2;
// identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET
// NULL do not change row counts and so are not detected here.
let inbound = read_relationships_inbound(conn, target_table)?;
let mut before_counts: Vec<i64> = Vec::with_capacity(inbound.len());
for r in &inbound {
before_counts.push(count_rows(conn, &r.other_table)?);
}
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// RETURNING (3g): one pass deletes and yields the rows as they
// were *before* deletion. `rows_affected` is the count of
// directly-deleted rows either way (RETURNING does not yield
// cascade-deleted child rows, so data.rows.len() == direct
// deletes), which keeps the self-ref cascade correction below
// valid. The cascade pre-count was already captured above.
let (rows_affected, data) = if returning {
let data = run_returning(conn, sql, &[], target_table)?;
(data.rows.len(), data)
} else {
let n = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
(
n,
DataResult {
table_name: target_table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
)
};
// Compare child-table counts after the delete; positive diffs
// are cascade effects. Collect the cascaded tables so the
// persistence phase rewrites their CSVs too.
let mut cascade: Vec<CascadeEffect> = Vec::new();
let mut rewritten_tables: Vec<String> = vec![target_table.to_string()];
for (rel, before_count) in inbound.iter().zip(before_counts.iter()) {
let after_count = count_rows(conn, &rel.other_table)?;
let mut rows_changed = before_count - after_count;
// A self-referential FK (child == target): the before/after
// diff also covers the directly-deleted rows, which are
// already reported in `rows_affected` and are not cascade
// effects. Subtract them so the summary reports only the rows
// removed *via* the self-reference.
let self_referential = rel.other_table == target_table;
if self_referential {
rows_changed -= rows_affected as i64;
}
if rows_changed > 0 {
cascade.push(CascadeEffect {
relationship_name: rel.name.clone(),
child_table: rel.other_table.clone(),
rows_changed,
action: rel.on_delete,
});
// The target's CSV is already queued; only add a distinct
// child table (a self-ref child is the target itself).
if !self_referential {
rewritten_tables.push(rel.other_table.clone());
}
}
}
let changes = Changes {
schema_dirty: false,
rewritten_tables,
..Changes::default()
};
finalize_persistence(conn, persistence, source, &changes)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(DeleteResult {
rows_affected,
cascade,
data,
})
}
/// Execute a grammar-validated SQL `SELECT` and collect its
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
/// Amendment 1).
///
/// Per-column playground types are recovered from the engine's
/// column-origin metadata (`column_table_name` /
/// `column_origin_name`, surfaced by rusqlite's
/// `columns_with_metadata`). The Amendment-1 empirical probe
/// confirmed the metadata follows through non-recursive CTEs,
/// scalar subqueries, derived tables, set ops, and JOINs; only
/// computed projections and recursive-CTE result columns return
/// `None`. The renderer (ADR-0016) handles typed columns
/// (bool → true/false, etc.) and falls back to neutral
/// alignment for `None`.
/// Execute a grammar-validated SQL DML statement carrying a
/// `RETURNING` clause and collect its returned rows into a
/// [`DataResult`] (ADR-0033 §5, sub-phase 3g).
///
/// A `RETURNING` DML, when stepped, *both* performs the mutation
/// *and* yields one result row per affected row — so `query_map`
/// does the write and the read in one pass. Result-column
/// playground types are recovered via the same column-origin path
/// SELECT uses (`resolve_select_column_types`), so a bare-column
/// `RETURNING` ref renders with its playground type; computed
/// projections stay typeless. `params` carries the bound values for
/// the `shortid` auto-fill rewrite (empty on the verbatim path).
///
/// `table_name` labels the result for the renderer; the columns are
/// the RETURNING projection, which may not be the table's columns
/// (aliases, expressions), exactly as for a SELECT.
fn run_returning(
conn: &Connection,
sql: &str,
params: &[rusqlite::types::Value],
table_name: &str,
) -> Result<DataResult, DbError> {
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
let column_names: Vec<String> =
stmt.column_names().into_iter().map(String::from).collect();
let col_count = column_names.len();
let column_types = resolve_select_column_types(conn, &stmt);
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
for i in 0..col_count {
cells.push(row.get(i)?);
}
Ok(cells)
})
.map_err(DbError::from_rusqlite)?;
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
rows.push(
cells
.into_iter()
.enumerate()
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
.collect(),
);
}
Ok(DataResult {
table_name: table_name.to_string(),
columns: column_names,
column_types,
rows,
})
}
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
debug!(sql = %sql, "run_select");
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
let column_names: Vec<String> = stmt
.column_names()
.into_iter()
.map(String::from)
.collect();
let col_count = column_names.len();
let column_types = resolve_select_column_types(conn, &stmt);
let rows_iter = stmt
.query_map([], |row| {
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
for i in 0..col_count {
cells.push(row.get(i)?);
}
Ok(cells)
})
.map_err(DbError::from_rusqlite)?;
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
rows.push(
cells
.into_iter()
.enumerate()
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
.collect(),
);
}
Ok(DataResult {
table_name: String::new(),
columns: column_names,
column_types,
rows,
})
}
/// Resolve playground types for each result column of a
/// prepared SELECT statement (ADR-0032 §12 + Amendment 1).
///
/// For each result column, query the engine's column-origin
/// metadata. If both `table_name` and `origin_name` are
/// populated (the result column traces back to a base-table
/// column), look up the playground type in
/// `__rdbms_playground_columns`. Otherwise the slot stays
/// `None` — Amendment 1 documents that recursive-CTE result
/// columns and computed projections are the only structural
/// classes that don't follow through.
fn resolve_select_column_types(
conn: &Connection,
stmt: &rusqlite::Statement,
) -> Vec<Option<Type>> {
let metas = stmt.columns_with_metadata();
if metas.is_empty() {
return Vec::new();
}
// Prepare the lookup once; reuse across columns.
let mut lookup = match conn.prepare(&format!(
"SELECT user_type FROM {META_TABLE} \
WHERE table_name = ?1 COLLATE NOCASE \
AND column_name = ?2 COLLATE NOCASE"
)) {
Ok(s) => s,
Err(_) => return vec![None; metas.len()],
};
metas
.iter()
.map(|m| {
let table = m.table_name()?;
let origin = m.origin_name()?;
lookup
.query_row(rusqlite::params![table, origin], |row| {
row.get::<_, String>(0)
})
.ok()
.and_then(|kw| kw.parse::<Type>().ok())
})
.collect()
}
/// Build the parameterised `SELECT … FROM …` statement for a
/// `show data` query (ADR-0026 §5–§6). Separated from
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
/// PLAN` against the *exact* same statement (ADR-0028 §2).
///
/// A `limit` implies a stable primary-key `ORDER BY` so
/// `limit n` is "first n by primary key" rather than an
/// arbitrary subset.
fn build_query_data_sql(
schema: &ReadSchema,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> (String, Vec<rusqlite::types::Value>) {
let cols_csv = schema
.columns
.iter()
.map(|c| quote_ident(&c.name))
.collect::<Vec<_>>()
.join(", ");
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let where_sql = filter.map_or_else(String::new, |expr| {
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
});
let (order_sql, limit_sql) = limit.map_or_else(
|| (String::new(), String::new()),
|n| {
let order = if schema.primary_key.is_empty() {
String::new()
} else {
let pk = schema
.primary_key
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
format!(" ORDER BY {pk}")
};
params.push(rusqlite::types::Value::Integer(
i64::try_from(n).unwrap_or(i64::MAX),
));
(order, format!(" LIMIT ?{}", params.len()))
},
);
let sql = format!(
"SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
ident = quote_ident(table),
);
(sql, params)
}
fn do_query_data(
conn: &Connection,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> Result<DataResult, DbError> {
debug!(table = %table, limit = ?limit, "query_data");
let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
schema.columns.iter().map(|c| c.user_type).collect();
let (sql, params) = build_query_data_sql(&schema, table, filter, limit);
debug!(sql = %sql, "query_data");
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(column_names.len());
for i in 0..column_names.len() {
let v: rusqlite::types::Value = row.get(i)?;
cells.push(v);
}
Ok(cells)
})
.map_err(DbError::from_rusqlite)?;
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
for r in rows_iter {
let cells = r.map_err(DbError::from_rusqlite)?;
let formatted: Vec<Option<String>> = cells
.into_iter()
.zip(column_types.iter())
.map(|(v, ty)| format_cell(v, *ty))
.collect();
rows.push(formatted);
}
Ok(DataResult {
table_name: table.to_string(),
columns: column_names,
column_types,
rows,
})
}
fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String> {
use rusqlite::types::Value as V;
match value {
V::Null => None,
V::Integer(i) => Some(if matches!(ty, Some(Type::Bool)) {
(if i == 0 { "false" } else { "true" }).to_string()
} else {
i.to_string()
}),
V::Real(r) => Some(format!("{r}")),
V::Text(s) => Some(s),
V::Blob(b) => Some(format!("<blob {} bytes>", b.len())),
}
}
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). Matches the inner command, builds the exact
/// SQL it would otherwise run via the shared `build_*_sql`
/// helpers, runs `EXPLAIN QUERY PLAN` against it (which never
/// executes the statement), and pairs the plan rows with a
/// standard-SQL display form of the statement.
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
debug!("explain_plan");
let (exec_sql, params) = match query {
Command::ShowData {
name,
filter,
limit,
} => {
let schema = read_schema(conn, name)?;
build_query_data_sql(&schema, name, filter.as_ref(), *limit)
}
Command::Update {
table,
assignments,
filter,
} => {
let schema = read_schema(conn, table)?;
build_update_sql(&schema, table, assignments, filter)?
}
Command::Delete { table, filter } => {
let schema = read_schema(conn, table)?;
build_delete_sql(&schema, table, filter)
}
// ADR-0039: advanced-mode SQL commands carry their validated
// SQL text verbatim (grammar-as-text), so there is nothing to
// synthesise — run EXPLAIN QUERY PLAN over the text directly,
// with no bound parameters. `EXPLAIN QUERY PLAN` never
// executes the statement, so this is safe for the destructive
// verbs too.
Command::Select { sql }
| Command::SqlInsert { sql, .. }
| Command::SqlUpdate { sql, .. }
| Command::SqlDelete { sql, .. } => (sql.clone(), Vec::new()),
other => {
// The grammar only ever wraps the three explainable
// commands; a different inner command means a
// synthesised `Command::Explain` (tests, scripting).
return Err(DbError::Unsupported(format!(
"cannot explain `{}`",
other.verb()
)));
}
};
let rows = run_explain_query_plan(conn, &exec_sql, &params)?;
let display_sql = inline_params_for_display(&exec_sql, &params);
debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
Ok(QueryPlan { display_sql, rows })
}
/// Prepare `EXPLAIN QUERY PLAN <sql>` and read back its tree
/// rows. The inner statement's parameters are bound so the
/// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines
/// the plan from structure, not parameter values (ADR-0028 §2).
fn run_explain_query_plan(
conn: &Connection,
sql: &str,
params: &[rusqlite::types::Value],
) -> Result<Vec<ExplainRow>, DbError> {
let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?;
// `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)`
// — the `notused` column at index 2 is dropped.
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(ExplainRow {
id: row.get(0)?,
parent: row.get(1)?,
detail: row.get(3)?,
})
})
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for r in rows_iter {
out.push(r.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Render execution SQL as human-facing display SQL by inlining
/// each `?N` placeholder as a standard-SQL literal (ADR-0028
/// §3). The scan is quote-aware — a `?N`-shaped run inside a
/// double-quoted identifier is left untouched — and the
/// trailing `;` is dropped so the result reads as a query.
fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String {
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
let mut in_quote = false;
while let Some(c) = chars.next() {
match c {
'"' => {
in_quote = !in_quote;
out.push('"');
}
'?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => {
let mut digits = String::new();
while let Some(d) = chars.peek() {
if d.is_ascii_digit() {
digits.push(*d);
chars.next();
} else {
break;
}
}
match digits
.parse::<usize>()
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|idx| params.get(idx))
{
Some(value) => out.push_str(&sql_literal(value)),
// Out of range — leave the placeholder verbatim.
None => {
out.push('?');
out.push_str(&digits);
}
}
}
other => out.push(other),
}
}
out.trim_end().trim_end_matches(';').trim_end().to_string()
}
/// Render a bound parameter as a standard-SQL literal for the
/// display SQL (ADR-0028 §3). Text is single-quoted with
/// embedded quotes doubled; a real with no fractional part
/// keeps a `.0` so it still reads as a real.
fn sql_literal(value: &rusqlite::types::Value) -> String {
use rusqlite::types::Value as V;
match value {
V::Null => "NULL".to_string(),
V::Integer(i) => i.to_string(),
V::Real(r) => {
let s = r.to_string();
if s.contains(['.', 'e', 'E']) {
s
} else {
format!("{s}.0")
}
}
V::Text(s) => format!("'{}'", s.replace('\'', "''")),
V::Blob(bytes) => {
let mut hex = String::with_capacity(bytes.len() * 2 + 3);
hex.push_str("x'");
for b in bytes {
hex.push_str(&format!("{b:02x}"));
}
hex.push('\'');
hex
}
}
}
fn read_relationships_outbound(
conn: &Connection,
table: &str,
) -> Result<Vec<RelationshipEnd>, DbError> {
let mut stmt = conn
.prepare(&format!(
"SELECT name, parent_table, parent_column, child_column, on_delete, on_update \
FROM {REL_TABLE} \
WHERE child_table = ?1 \
ORDER BY name;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([table], |row| {
let on_delete: String = row.get(4)?;
let on_update: String = row.get(5)?;
Ok(RelationshipEnd {
name: row.get(0)?,
other_table: row.get(1)?,
other_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()),
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_columns: decode_rel_columns(row.get::<_, String>(2)?.as_str()),
local_columns: decode_rel_columns(row.get::<_, String>(3)?.as_str()),
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> {
debug!(path = %project_path.display(), "rebuild_from_text");
let yaml_path = project_path.join(PROJECT_YAML);
let data_dir = project_path.join(DATA_DIR);
let yaml_body =
std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal {
operation: "read",
path: yaml_path.clone(),
message: e.to_string(),
})?;
let snapshot = parse_schema(&yaml_body).map_err(|e| DbError::PersistenceFatal {
operation: "parse",
path: yaml_path.clone(),
message: e.to_string(),
})?;
conn.execute_batch("PRAGMA foreign_keys = OFF;")
.map_err(DbError::from_rusqlite)?;
let result = (|| -> Result<(), DbError> {
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
// 0. Wipe any existing user tables + metadata so the
// rebuild can start from a clean slate. This step
// is a no-op on a freshly-created database (the
// silent rebuild on missing-`.db`); on the explicit
// `rebuild` command it replaces the live state with
// the text-source state per ADR-0015 §7.
let existing_tables = do_list_tables(&tx)?;
for name in &existing_tables {
tx.execute_batch(&format!(
"DROP TABLE {ident};",
ident = quote_ident(name)
))
.map_err(DbError::from_rusqlite)?;
}
tx.execute_batch(&format!(
"DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};"
))
.map_err(DbError::from_rusqlite)?;
// 0b. The table-level CHECK metadata is the source of truth that
// the engine cannot report (ADR-0035 §4a.3), so — like
// META/REL — it is wiped and repopulated from the YAML
// snapshot here (step 3b). Without this a fresh rebuild
// (missing `.db`) would enforce the CHECK via the recreated
// DDL but leave `CHECK_TABLE` empty, so `describe` / `DROP
// CONSTRAINT` / a later save would lose it. The rebuild also
// **migrates** a pre-§4g table that predates the `name`
// column (the rebuild-only migration, ADR-0035 §4g): add it
// if absent before repopulating with names.
if !check_table_has_name_column(&tx)? {
tx.execute_batch(&format!("ALTER TABLE {CHECK_TABLE} ADD COLUMN name TEXT;"))
.map_err(DbError::from_rusqlite)?;
}
tx.execute_batch(&format!("DELETE FROM {CHECK_TABLE};"))
.map_err(DbError::from_rusqlite)?;
// 1. Recreate user tables with FK constraints inline.
for table in &snapshot.tables {
let read_schema = build_read_schema(table, &snapshot.relationships);
let ddl = schema_to_ddl(&table.name, &read_schema);
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
}
// 2. Column-type metadata.
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
))
.map_err(DbError::from_rusqlite)?;
for table in &snapshot.tables {
for col in &table.columns {
stmt.execute([
table.name.as_str(),
col.name.as_str(),
col.user_type.keyword(),
])
.map_err(DbError::from_rusqlite)?;
}
}
}
// 3. Relationship metadata.
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {REL_TABLE} \
(name, parent_table, parent_column, child_table, child_column, \
on_delete, on_update) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
))
.map_err(DbError::from_rusqlite)?;
for rel in &snapshot.relationships {
let parent_cols = encode_rel_columns(&rel.parent_columns);
let child_cols = encode_rel_columns(&rel.child_columns);
stmt.execute([
rel.name.as_str(),
rel.parent_table.as_str(),
parent_cols.as_str(),
rel.child_table.as_str(),
child_cols.as_str(),
rel.on_delete.keyword(),
rel.on_update.keyword(),
])
.map_err(DbError::from_rusqlite)?;
}
}
// 3b. Table-level CHECK metadata (ADR-0035 §4a.3 / §4g) — in
// declaration order (`seq`), carrying the optional name so a
// named CHECK round-trips through the rebuild.
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
VALUES (?1, ?2, ?3, ?4);"
))
.map_err(DbError::from_rusqlite)?;
for table in &snapshot.tables {
for (seq, check) in table.check_constraints.iter().enumerate() {
stmt.execute(rusqlite::params![
table.name,
seq as i64,
check.expr,
check.name,
])
.map_err(DbError::from_rusqlite)?;
}
}
}
// 4. Project metadata: overwrite the configure-time
// `created_at` with the YAML's authoritative value.
tx.execute(
&format!(
"INSERT INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
),
[snapshot.created_at.as_str()],
)
.map_err(DbError::from_rusqlite)?;
// 5. Load each table's rows (if a CSV is present).
for table in &snapshot.tables {
let csv_path = data_dir.join(format!("{}.csv", table.name));
if !csv_path.exists() {
continue;
}
load_table_csv(&tx, table, &csv_path)?;
}
// 5b. Recreate indexes (ADR-0025). Done after the data
// load — the result is identical either way, and
// this keeps the structural steps (tables, FKs,
// data) ahead of the derived index objects.
for index in &snapshot.indexes {
let cols = index
.columns
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
// ADR-0035 §4d: a UNIQUE index round-trips its uniqueness, so
// re-emit `CREATE UNIQUE INDEX` — otherwise a rebuild would
// silently demote it to a plain index.
let unique_kw = if index.unique { "UNIQUE " } else { "" };
tx.execute_batch(&format!(
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
idx = quote_ident(&index.name),
tbl = quote_ident(&index.table),
))
.map_err(DbError::from_rusqlite)?;
}
// 6. Verify FK consistency before committing.
{
let mut check = tx
.prepare("PRAGMA foreign_key_check;")
.map_err(DbError::from_rusqlite)?;
let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
if rows.next().map_err(DbError::from_rusqlite)?.is_some() {
return Err(DbError::PersistenceFatal {
operation: "rebuild",
path: yaml_path.clone(),
message: "rebuilt data violates foreign-key constraints".to_string(),
});
}
}
// 7. Append `history.log` if this rebuild was
// user-initiated (the silent on-load case has
// `source = None`).
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
})();
let pragma_result = conn
.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(DbError::from_rusqlite);
result.and(pragma_result)
}
/// Build a `ReadSchema` for `table` that includes any
/// relationships from the snapshot in which `table` is the
/// child. The output drives `schema_to_ddl` so the resulting
/// CREATE TABLE has the FKs inline.
fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) -> ReadSchema {
let columns: Vec<ReadColumn> = table
.columns
.iter()
.map(|c| ReadColumn {
name: c.name.clone(),
sqlite_type: c.user_type.sqlite_strict_type().to_string(),
notnull: c.not_null,
primary_key: table.primary_key.contains(&c.name),
unique: c.unique,
default_sql: c.default.clone(),
check: c.check.clone(),
user_type: Some(c.user_type),
})
.collect();
let foreign_keys: Vec<ReadForeignKey> = relationships
.iter()
.filter(|r| r.child_table == table.name)
.map(|r| ReadForeignKey {
parent_table: r.parent_table.clone(),
parent_columns: r.parent_columns.clone(),
child_columns: r.child_columns.clone(),
on_delete: r.on_delete,
on_update: r.on_update,
})
.collect();
ReadSchema {
columns,
primary_key: table.primary_key.clone(),
foreign_keys,
unique_constraints: table.unique_constraints.clone(),
check_constraints: table.check_constraints.clone(),
}
}
/// Read `csv_path` and INSERT each row into `table.name`.
/// Failures are wrapped in `DbError::RebuildRowFailed` with
/// row number and table name per ADR-0015 §7.
fn load_table_csv(
tx: &rusqlite::Transaction<'_>,
table: &TableSchema,
csv_path: &Path,
) -> Result<(), DbError> {
let body = std::fs::read_to_string(csv_path).map_err(|e| DbError::PersistenceFatal {
operation: "read",
path: csv_path.to_path_buf(),
message: e.to_string(),
})?;
let parsed = parse_csv(&body).map_err(|e| DbError::PersistenceFatal {
operation: "parse",
path: csv_path.to_path_buf(),
message: e.to_string(),
})?;
if parsed.rows.is_empty() {
return Ok(());
}
// Header sanity check: column names must match the YAML
// schema's column order. A mismatch is a hand-edit hazard;
// surfacing it as a fatal error is better than silently
// mis-aligning columns.
let expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
let header_strs: Vec<&str> = parsed.header.iter().map(String::as_str).collect();
if header_strs != expected {
return Err(DbError::PersistenceFatal {
operation: "validate",
path: csv_path.to_path_buf(),
message: format!(
"CSV header {:?} does not match table columns {:?}",
parsed.header, expected,
),
});
}
let cols_csv = table
.columns
.iter()
.map(|c| quote_ident(&c.name))
.collect::<Vec<_>>()
.join(", ");
let placeholders = (1..=table.columns.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
ident = quote_ident(&table.name),
);
let mut stmt = tx.prepare(&sql).map_err(DbError::from_rusqlite)?;
for (idx, raw_row) in parsed.rows.iter().enumerate() {
// Row number reported as a 1-based file line: header
// is line 1, so the first data row is line 2.
let row_number = idx + 2;
if raw_row.len() != table.columns.len() {
return Err(DbError::RebuildRowFailed {
table: table.name.clone(),
csv_path: csv_path.to_path_buf(),
row_number,
detail: format!(
"row has {} field(s) but table has {} column(s)",
raw_row.len(),
table.columns.len(),
),
});
}
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(raw_row.len());
for (col, raw_cell) in table.columns.iter().zip(raw_row.iter()) {
let cell = decode_cell(col.user_type, raw_cell).map_err(|detail| {
DbError::RebuildRowFailed {
table: table.name.clone(),
csv_path: csv_path.to_path_buf(),
row_number,
detail: format!("column `{}`: {detail}", col.name),
}
})?;
params.push(cell_value_to_sqlite(&cell));
}
stmt.execute(rusqlite::params_from_iter(params.iter()))
.map_err(|e| DbError::RebuildRowFailed {
table: table.name.clone(),
csv_path: csv_path.to_path_buf(),
row_number,
detail: e.to_string(),
})?;
}
Ok(())
}
fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
use rusqlite::types::Value;
match cell {
CellValue::Null => Value::Null,
CellValue::Integer(n) => Value::Integer(*n),
CellValue::Real(f) => Value::Real(*f),
CellValue::Text(s) => Value::Text(s.clone()),
CellValue::Blob(b) => Value::Blob(b.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn db() -> Database {
Database::open(":memory:").expect("open in-memory")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec::new(name, ty)
}
/// Convenience: a `serial`-PK table with a single `id` column.
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
db.create_table(
name.to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.expect("create table")
}
#[tokio::test]
async fn open_in_memory_succeeds() {
let _ = db();
}
#[tokio::test]
async fn create_table_with_serial_pk_appears_in_list() {
let db = db();
make_id_table(&db, "Customers").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Customers".to_string()]);
}
#[tokio::test]
async fn create_table_with_serial_pk_describes_correctly() {
let db = db();
let desc = make_id_table(&db, "Customers").await;
assert_eq!(desc.name, "Customers");
assert_eq!(desc.columns.len(), 1);
let id = &desc.columns[0];
assert_eq!(id.name, "id");
assert!(id.primary_key);
assert_eq!(id.user_type, Some(Type::Serial));
assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
}
#[tokio::test]
async fn create_table_with_text_pk_works() {
let db = db();
let desc = db
.create_table(
"Customers".to_string(),
vec![col("email", Type::Text)],
vec!["email".to_string()],
None)
.await
.unwrap();
assert_eq!(desc.columns.len(), 1);
assert_eq!(desc.columns[0].name, "email");
assert_eq!(desc.columns[0].user_type, Some(Type::Text));
assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_with_compound_pk_works() {
let db = db();
let desc = db
.create_table(
"OrderLines".to_string(),
vec![col("order_id", Type::Int), col("product_id", Type::Int)],
vec!["order_id".to_string(), "product_id".to_string()],
None)
.await
.unwrap();
assert_eq!(desc.columns.len(), 2);
assert!(desc.columns.iter().all(|c| c.primary_key));
}
#[tokio::test]
async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
// The grammar lets users try anything; the DB layer just
// does what they ask.
let db = db();
let desc = db
.create_table(
"T".to_string(),
vec![col("flag", Type::Bool)],
vec!["flag".to_string()],
None)
.await
.unwrap();
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_rejects_zero_columns() {
let db = db();
let err = db
.create_table("T".to_string(), Vec::new(), Vec::new(), None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn create_table_rejects_an_internal_name() {
// A new table may not take an internal `__rdbms_*` name — it would
// be hidden from `list_tables`. The advanced-SQL path rejects this
// at parse; the shared executor guards every other path (the
// simple-mode DSL slot and `create m:n … as`, ADR-0045).
let db = db();
let err = db
.create_table(
"__rdbms_sneaky".to_string(),
vec![col("id", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}");
assert!(db.list_tables().await.unwrap().is_empty());
}
#[tokio::test]
async fn drop_table_removes_it_from_list() {
let db = db();
make_id_table(&db, "T").await;
db.drop_table("T".to_string(), None).await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(tables.is_empty());
}
#[tokio::test]
async fn add_column_appends_to_existing_table() {
let db = db();
make_id_table(&db, "Customers").await;
let result = db
.add_column("Customers".to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None)
.await
.unwrap();
let desc = &result.description;
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["id", "Name"]);
let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
assert_eq!(name_col.user_type, Some(Type::Text));
assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
assert!(result.client_side_notes.is_empty(), "no auto-fill for plain types");
}
#[tokio::test]
async fn user_facing_types_round_trip_through_metadata() {
let db = db();
// Create with a serial PK and add columns of every type
// that would otherwise be erased by SQLite (date,
// datetime, decimal — all backed by TEXT).
make_id_table(&db, "T").await;
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
db.add_column("T".to_string(), ColumnSpec::new(format!("c_{ty}"), ty), None)
.await
.unwrap();
}
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id_col.user_type, Some(Type::Serial));
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
let col_name = format!("c_{ty}");
let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
}
}
#[tokio::test]
async fn list_tables_excludes_internal_metadata_table() {
let db = db();
make_id_table(&db, "Visible").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Visible".to_string()]);
// Metadata table is present in the underlying schema but
// hidden from list_tables.
}
#[tokio::test]
async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
let db = db();
// Create with a date column.
db.create_table(
"T".to_string(),
vec![col("when", Type::Date)],
vec!["when".to_string()],
None)
.await
.unwrap();
let before = db.describe_table("T".to_string(), None).await.unwrap();
assert_eq!(before.columns[0].user_type, Some(Type::Date));
// Drop it.
db.drop_table("T".to_string(), None).await.unwrap();
// Recreate with a different type for the same-named column;
// the metadata for the new table must reflect the new type
// (i.e. metadata from the previous incarnation must not
// bleed through).
db.create_table(
"T".to_string(),
vec![col("when", Type::DateTime)],
vec!["when".to_string()],
None)
.await
.unwrap();
let after = db.describe_table("T".to_string(), None).await.unwrap();
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
}
#[tokio::test]
async fn add_column_for_each_value_type() {
let db = db();
make_id_table(&db, "T").await;
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
let col_name = format!("c_{ty}");
db.add_column("T".to_string(), ColumnSpec::new(col_name.clone(), ty), None)
.await
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
}
let desc = db.describe_table("T".to_string(), None).await.unwrap();
// 5 user columns + the id PK column.
assert_eq!(desc.columns.len(), 6);
}
#[tokio::test]
async fn add_column_serial_to_empty_table_succeeds() {
// ADR-0018 §6: serial / shortid via add_column are now
// allowed. Empty-table case: column added, no auto-fill
// [client-side] note (nothing to populate).
let db = db();
make_id_table(&db, "T").await;
let result = db
.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Serial), None)
.await
.unwrap();
let code = result
.description
.columns
.iter()
.find(|c| c.name == "code")
.expect("code column added");
assert_eq!(code.user_type, Some(Type::Serial));
assert!(
!code.primary_key,
"non-PK serial: column is not the PK (id remains the PK)"
);
assert!(
result.client_side_notes.is_empty(),
"empty table: no auto-fill note"
);
}
/// Helper: build a table with `id` (serial PK) + `Name`
/// (text) and insert N rows, populating just `Name`.
async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) {
make_id_table(db, table).await;
db.add_column(table.to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None)
.await
.unwrap();
for i in 0..count {
db.insert(
table.to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text(format!("row{i}"))],
None,
)
.await
.unwrap();
}
}
#[tokio::test]
async fn add_column_serial_to_non_empty_table_auto_fills() {
let db = db();
make_table_with_n_rows(&db, "T", 3).await;
let result = db
.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
let seq = result
.description
.columns
.iter()
.find(|c| c.name == "seq")
.unwrap();
assert_eq!(seq.user_type, Some(Type::Serial));
assert!(!result.client_side_notes.is_empty(), "auto-fill note expected");
assert!(
result.client_side_notes[0].contains("3 row(s) given auto-generated serial"),
"unexpected note: {:?}",
result.client_side_notes
);
// Verify the column is populated 1..3.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
let mut filled: Vec<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
.collect();
filled.sort();
assert_eq!(filled, vec![1, 2, 3]);
}
#[tokio::test]
async fn add_column_shortid_to_non_empty_table_auto_fills() {
let db = db();
make_table_with_n_rows(&db, "T", 3).await;
let result = db
.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::ShortId), None)
.await
.unwrap();
let tag = result
.description
.columns
.iter()
.find(|c| c.name == "tag")
.unwrap();
assert_eq!(tag.user_type, Some(Type::ShortId));
assert!(!result.client_side_notes.is_empty(), "auto-fill note expected");
assert!(
result.client_side_notes[0].contains("3 row(s) given auto-generated shortid"),
"unexpected note: {:?}",
result.client_side_notes
);
// Verify each row has a non-null shortid value.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
for row in &data.rows {
let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled");
assert!(
v.len() >= 10 && v.len() <= 12,
"shortid length out of range: {v}"
);
}
}
#[tokio::test]
async fn insert_auto_fills_non_pk_serial_column_sequentially() {
// ADR-0018 §5: non-PK serial columns get MAX(col)+1
// automatically when the user omits them. PK serial
// columns continue to use SQLite's rowid alias.
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
// Insert three rows providing only `Name`. The seq
// column should auto-fill 1, 2, 3.
for n in ["a", "b", "c"] {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text(n.to_string())],
None,
)
.await
.unwrap();
}
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
let mut values: Vec<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
.collect();
values.sort();
assert_eq!(values, vec![1, 2, 3]);
}
#[tokio::test]
async fn insert_non_pk_serial_continues_past_explicit_value() {
// If the user explicitly inserts a high value, MAX+1
// sequencing jumps past it. Gappy sequences are
// accepted (ADR-0018 Resolution 2).
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
// Insert with explicit seq=100.
db.insert(
"T".to_string(),
Some(vec!["Name".to_string(), "seq".to_string()]),
vec![Value::Text("a".to_string()), Value::Number("100".to_string())],
None,
)
.await
.unwrap();
// Next omitted-seq insert should auto-fill 101.
db.insert(
"T".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text("b".to_string())],
None,
)
.await
.unwrap();
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
let mut values: Vec<i64> = data
.rows
.iter()
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
.collect();
values.sort();
assert_eq!(values, vec![100, 101]);
}
#[tokio::test]
async fn add_column_serial_emits_unique_constraint() {
// Non-PK serial gains UNIQUE per ADR-0018 §4. Verify by
// attempting to UPDATE all rows to the same value and
// confirming the engine refuses.
let db = db();
make_table_with_n_rows(&db, "T", 2).await;
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
// Attempt to UPDATE one row to have the same `seq` value
// as the other — should violate UNIQUE.
let err = db
.update(
"T".to_string(),
vec![("seq".to_string(), Value::Number("1".to_string()))],
RowFilter::AllRows,
None,
)
.await
.unwrap_err();
// SQLite reports as a constraint violation; classified
// as UniqueViolation.
match err {
DbError::Sqlite { kind, .. } => {
assert_eq!(kind, SqliteErrorKind::UniqueViolation);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn create_table_duplicate_returns_already_exists() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.create_table(
"T".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn drop_nonexistent_table_returns_no_such_table() {
let db = db();
let err = db.drop_table("Ghost".to_string(), None).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn add_column_to_missing_table_returns_no_such_table() {
let db = db();
let err = db
.add_column("Ghost".to_string(), ColumnSpec::new("x".to_string(), Type::Text), None)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
// --- drop_column / rename_column / change_column_type ---
#[tokio::test]
async fn drop_column_removes_column_and_data() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None)
.await
.unwrap();
db.insert(
"T".to_string(),
None,
vec![Value::Number("42".to_string())],
None,
)
.await
.unwrap();
let result = db
.drop_column("T".to_string(), "Score".to_string(), false, None)
.await
.unwrap();
let names: Vec<_> = result
.description
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, vec!["id"]);
// Row data still accessible (id was preserved); the
// dropped column is gone from the projection.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
assert_eq!(data.columns, vec!["id".to_string()]);
assert_eq!(data.rows.len(), 1);
}
#[tokio::test]
async fn drop_column_refuses_primary_key() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.drop_column("T".to_string(), "id".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
let msg = format!("{err}");
assert!(msg.to_lowercase().contains("primary"), "{msg}");
}
#[tokio::test]
async fn drop_column_refuses_column_in_a_relationship() {
let db = db();
// Customers(id PK) ← Orders(cust_id FK)
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["cust_id".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Try to drop the FK column on the child side.
let err = db
.drop_column("Orders".to_string(), "cust_id".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
assert!(format!("{err}").contains("relationship"));
}
#[tokio::test]
async fn drop_column_for_missing_column_errors() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.drop_column("T".to_string(), "Ghost".to_string(), false, None)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchColumn),
other => panic!("unexpected error: {other:?}"),
}
}
// --- indexes (ADR-0025) -----------------------------------
/// A `serial`-PK table with one extra text `Email` column —
/// something indexable.
async fn make_indexable_table(db: &Database, name: &str) {
make_id_table(db, name).await;
db.add_column(name.to_string(), ColumnSpec::new("Email".to_string(), Type::Text), None)
.await
.expect("add Email column");
}
#[tokio::test]
async fn add_index_appears_in_description() {
let db = db();
make_indexable_table(&db, "Customers").await;
let desc = db
.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("add index");
assert_eq!(desc.indexes.len(), 1);
assert_eq!(desc.indexes[0].name, "idx_email");
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
}
#[tokio::test]
async fn add_index_auto_generates_name() {
let db = db();
make_indexable_table(&db, "Customers").await;
let desc = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("add index");
assert_eq!(desc.indexes[0].name, "Customers_Email_idx");
}
#[tokio::test]
async fn add_index_composite_auto_name_joins_columns() {
let db = db();
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None)
.await
.unwrap();
db.add_column("Orders".to_string(), ColumnSpec::new("Day".to_string(), Type::Date), None)
.await
.unwrap();
let desc = db
.add_index(
None,
"Orders".to_string(),
vec!["CustId".to_string(), "Day".to_string()],
None,
)
.await
.expect("add index");
assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx");
assert_eq!(
desc.indexes[0].columns,
vec!["CustId".to_string(), "Day".to_string()]
);
}
#[tokio::test]
async fn add_index_rejects_duplicate_name() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_column("Customers".to_string(), ColumnSpec::new("Nick".to_string(), Type::Text), None)
.await
.unwrap();
db.add_index(
Some("idx".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.add_index(
Some("idx".to_string()),
"Customers".to_string(),
vec!["Nick".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn add_index_rejects_redundant_column_set() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("a".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.add_index(
Some("b".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn add_index_rejects_missing_column() {
let db = db();
make_indexable_table(&db, "Customers").await;
let err = db
.add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None)
.await
.unwrap_err();
assert!(
matches!(
err,
DbError::Sqlite {
kind: SqliteErrorKind::NoSuchColumn,
..
}
),
"got {err:?}"
);
}
#[tokio::test]
async fn add_index_rejects_missing_table() {
let db = db();
let err = db
.add_index(None, "Ghost".to_string(), vec!["x".to_string()], None)
.await
.unwrap_err();
assert!(
matches!(
err,
DbError::Sqlite {
kind: SqliteErrorKind::NoSuchTable,
..
}
),
"got {err:?}"
);
}
#[tokio::test]
async fn drop_index_by_name_removes_it() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let desc = db
.drop_index(
IndexSelector::Named {
name: "idx_email".to_string(),
},
None,
)
.await
.expect("drop index");
assert!(desc.indexes.is_empty());
}
#[tokio::test]
async fn drop_index_by_columns_removes_it() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.unwrap();
let desc = db
.drop_index(
IndexSelector::Columns {
table: "Customers".to_string(),
columns: vec!["Email".to_string()],
},
None,
)
.await
.expect("drop index");
assert!(desc.indexes.is_empty());
}
#[tokio::test]
async fn drop_index_unknown_name_errors() {
let db = db();
make_indexable_table(&db, "Customers").await;
let err = db
.drop_index(
IndexSelector::Named {
name: "nope".to_string(),
},
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}");
}
#[tokio::test]
async fn drop_column_refuses_indexed_column_without_cascade() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let err = db
.drop_column("Customers".to_string(), "Email".to_string(), false, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
assert!(format!("{err}").contains("idx_email"), "got {err}");
}
#[tokio::test]
async fn drop_column_cascade_drops_covering_index() {
let db = db();
make_indexable_table(&db, "Customers").await;
db.add_index(
Some("idx_email".to_string()),
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let result = db
.drop_column("Customers".to_string(), "Email".to_string(), true, None)
.await
.expect("drop column --cascade");
assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]);
assert!(result.description.indexes.is_empty());
assert!(
result
.description
.columns
.iter()
.all(|c| c.name != "Email"),
);
}
#[tokio::test]
async fn rebuild_table_preserves_indexes() {
// `change column` rebuilds the table; an index on an
// unrelated column must survive the rebuild (ADR-0025).
let db = db();
make_indexable_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None)
.await
.unwrap();
db.add_index(
Some("idx_email".to_string()),
"T".to_string(),
vec!["Email".to_string()],
None,
)
.await
.unwrap();
let result = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Real,
ChangeColumnMode::Default,
None,
)
.await
.expect("change column type");
assert_eq!(result.description.indexes.len(), 1);
assert_eq!(result.description.indexes[0].name, "idx_email");
assert_eq!(
result.description.indexes[0].columns,
vec!["Email".to_string()]
);
}
#[tokio::test]
async fn rename_column_updates_schema_and_metadata() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Old".to_string(), Type::Text), None)
.await
.unwrap();
let desc = db
.rename_column("T".to_string(), "Old".to_string(), "New".to_string(), None)
.await
.unwrap();
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["id", "New"]);
// user_type metadata was preserved through the rename.
let new_col = desc.columns.iter().find(|c| c.name == "New").unwrap();
assert_eq!(new_col.user_type, Some(Type::Text));
}
#[tokio::test]
async fn rename_column_propagates_to_relationship_metadata() {
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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_columns == vec!["buyer_id".to_string()]);
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_columns == vec!["buyer_id".to_string()]);
assert!(
inbound.is_some(),
"expected inbound rel referencing `buyer_id`, got {:?}",
customers.inbound_relationships,
);
}
#[tokio::test]
async fn rename_column_refuses_collision() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
.await
.unwrap();
db.add_column("T".to_string(), ColumnSpec::new("B".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.rename_column("T".to_string(), "A".to_string(), "B".to_string(), None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn rename_column_refuses_identity_rename() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.rename_column("T".to_string(), "A".to_string(), "A".to_string(), None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn change_column_type_works_for_compatible_data() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None)
.await
.unwrap();
// Insert numeric-looking strings.
for v in ["1", "2", "3"] {
db.insert(
"T".to_string(),
Some(vec!["Score".to_string()]),
vec![Value::Text(v.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let score = result
.description
.columns
.iter()
.find(|c| c.name == "Score")
.unwrap();
assert_eq!(score.user_type, Some(Type::Int));
// Per ADR-0017 §6, the [client-side] note fires when
// any cell was rewritten (storage class change counts).
let note = result
.client_side
.expect("text -> int rewrites every cell; expected client-side note");
assert_eq!(note.transformed, 3);
assert_eq!(note.lossy, 0);
// Data preserved via the per-cell transformer.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows.len(), 3);
}
#[tokio::test]
async fn change_column_type_pk_without_inbound_fk_allowed() {
// ADR-0017 §4.1: a PK without inbound FKs may have its
// type changed freely (no cascade to worry about).
let db = db();
make_id_table(&db, "T").await;
// serial -> int (canonical "drop auto-increment" case).
db.change_column_type(
"T".to_string(),
"id".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
)
.await
.expect("serial -> int on PK without inbound FK should succeed");
}
#[tokio::test]
async fn change_column_type_pk_with_inbound_fk_refused_when_target_type_changes() {
// PK referenced by another table's FK; new type would
// change `fk_target_type` (serial -> text means
// fk_target_type goes Int -> Text). Refused.
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["cust_id".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
let err = db
.change_column_type(
"Customers".to_string(),
"id".to_string(),
Type::Text,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("referenced") || message.contains("relationship"),
"expected FK-cascade language: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_pk_with_inbound_fk_allowed_when_target_type_preserved() {
// PK referenced by another table's FK; serial -> int
// preserves `fk_target_type` (both Int). Allowed.
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["cust_id".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
db.change_column_type(
"Customers".to_string(),
"id".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
)
.await
.expect("serial -> int on FK-referenced PK should succeed");
}
// ---- ADR-0017 dry-run / mode / uniqueness coverage ----
#[tokio::test]
async fn change_column_type_refuses_lossy_by_default() {
// real -> int with fractional values: every cell is
// Lossy. Default mode refuses with a friendly diagnostic
// table.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None)
.await
.unwrap();
for v in ["3.14", "2.71"] {
db.insert(
"T".to_string(),
Some(vec!["Score".to_string()]),
vec![Value::Number(v.to_string())],
None,
)
.await
.unwrap();
}
let err = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("discard information"),
"expected lossy header: {message}"
);
assert!(
message.contains("--force-conversion"),
"expected hint about --force-conversion: {message}"
);
// Bordered diagnostic table is present.
assert!(message.contains('┌'), "expected bordered table: {message}");
assert!(
message.contains("(PK)"),
"expected PK header marker: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_force_conversion_accepts_lossy() {
// Same setup as above, but with --force-conversion the
// change succeeds and the [client-side] note carries
// the lossy count.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None)
.await
.unwrap();
for v in ["3.14", "2.71", "5.0"] {
db.insert(
"T".to_string(),
Some(vec!["Score".to_string()]),
vec![Value::Number(v.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Int,
ChangeColumnMode::ForceConversion,
None,
)
.await
.expect("--force-conversion should accept lossy rows");
let note = result.client_side.expect("transformations happened");
assert_eq!(note.transformed, 3);
// Two of three rows are fractional (lossy); 5.0 is clean.
assert_eq!(note.lossy, 2);
}
#[tokio::test]
async fn change_column_type_refuses_incompatible_even_with_force() {
// text "abc" -> int: incompatible. --force-conversion
// does NOT help (per ADR-0017 §5 / §2 step 3).
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
.await
.unwrap();
for v in ["abc", "123", "xyz"] {
db.insert(
"T".to_string(),
Some(vec!["Note".to_string()]),
vec![Value::Text(v.to_string())],
None,
)
.await
.unwrap();
}
for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
let err = db
.change_column_type(
"T".to_string(),
"Note".to_string(),
Type::Int,
mode,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("cannot be converted"),
"expected incompatible header for {mode:?}: {message}"
);
assert!(
!message.contains("--force-conversion"),
"incompatible refusal must NOT mention --force-conversion: {message}"
);
}
other => panic!("unexpected ({mode:?}): {other:?}"),
}
}
}
#[tokio::test]
async fn change_column_type_int_to_bool_with_zero_one_succeeds() {
// ADR-0017 §3 matrix: (Int, Bool) is per-cell-classified.
// Values 0/1 are Clean (storage class doesn't change); the
// transformer returns Value::Integer(0)/(1) unchanged, so
// is_non_identity is false for every cell. No
// [client-side] note is expected.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None)
.await
.unwrap();
for v in ["0", "1", "0"] {
db.insert(
"T".to_string(),
Some(vec!["Flag".to_string()]),
vec![Value::Number(v.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"Flag".to_string(),
Type::Bool,
ChangeColumnMode::Default,
None,
)
.await
.expect("int -> bool with all 0/1 values should succeed");
let flag = result
.description
.columns
.iter()
.find(|c| c.name == "Flag")
.unwrap();
assert_eq!(flag.user_type, Some(Type::Bool));
assert!(
result.client_side.is_none(),
"int -> bool with values that map identity should not fire a client-side note: {:?}",
result.client_side
);
// Data preserved.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows.len(), 3);
}
#[tokio::test]
async fn change_column_type_int_to_bool_refuses_other_values() {
// ADR-0017 §3 matrix: (Int, Bool) classifies any value
// other than 0/1 as Incompatible. A single offending row
// triggers a refusal, and --force-conversion does not
// help (incompatible is not lossy).
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None)
.await
.unwrap();
for v in ["0", "1", "2"] {
db.insert(
"T".to_string(),
Some(vec!["Flag".to_string()]),
vec![Value::Number(v.to_string())],
None,
)
.await
.unwrap();
}
for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
let err = db
.change_column_type(
"T".to_string(),
"Flag".to_string(),
Type::Bool,
mode,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("cannot be converted"),
"expected incompatible header for {mode:?}: {message}"
);
assert!(
message.contains("not 0 or 1"),
"expected per-cell reason naming the offending value for {mode:?}: {message}"
);
assert!(
!message.contains("--force-conversion"),
"incompatible refusal must NOT advertise --force-conversion for {mode:?}: {message}"
);
}
other => panic!("unexpected ({mode:?}): {other:?}"),
}
}
}
#[tokio::test]
async fn change_column_type_dont_convert_skips_client_side() {
// text -> int: under the per-cell matrix, "1"/"2"/"3"
// would all classify Clean and fire the [client-side]
// note. With --dont-convert we bypass that and rely on
// engine coercion; the note is suppressed.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None)
.await
.unwrap();
for v in ["1", "2", "3"] {
db.insert(
"T".to_string(),
Some(vec!["Score".to_string()]),
vec![Value::Text(v.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"Score".to_string(),
Type::Int,
ChangeColumnMode::DontConvert,
None,
)
.await
.expect("text->int with all-clean cells should succeed under --dont-convert");
assert!(
result.client_side.is_none(),
"--dont-convert must NOT emit a client-side note"
);
}
#[tokio::test]
async fn change_column_type_uniqueness_collision_on_pk_refused() {
// PK column with values 3.14 and 3.7: real -> int with
// --force-conversion would collapse both to 3, violating
// PK uniqueness. ADR-0017 §4.3: this is incompatible
// even under --force-conversion.
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Real)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
for v in ["3.14", "3.7"] {
db.insert(
"T".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number(v.to_string())],
None,
)
.await
.unwrap();
}
let err = db
.change_column_type(
"T".to_string(),
"id".to_string(),
Type::Int,
ChangeColumnMode::ForceConversion,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("collision") && message.contains("uniqueness"),
"expected collision diagnostic: {message}"
);
assert!(message.contains('┌'), "expected bordered table: {message}");
// Source rows column with PK column name in the header.
assert!(
message.contains("Source rows (id)"),
"expected PK-aware Source rows header: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_blob_target_refused_statically() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.change_column_type(
"T".to_string(),
"Note".to_string(),
Type::Blob,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn change_column_type_outbound_fk_refused() {
// Child-side FK column: §4.2 says always refuse,
// regardless of target type.
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["cust_id".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
let err = db
.change_column_type(
"Orders".to_string(),
"cust_id".to_string(),
Type::Real,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("foreign key") || message.contains("relationship"),
"expected outbound-FK refusal: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_no_op_when_no_rows() {
// Empty table: no cells to transform, no [client-side]
// note, and the structural change goes through.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
.await
.unwrap();
let result = db
.change_column_type(
"T".to_string(),
"Note".to_string(),
Type::Int,
ChangeColumnMode::Default,
None,
)
.await
.expect("empty table should still allow the type change");
assert!(
result.client_side.is_none(),
"no rows means no client-side note"
);
let note_col = result
.description
.columns
.iter()
.find(|c| c.name == "Note")
.unwrap();
assert_eq!(note_col.user_type, Some(Type::Int));
}
#[tokio::test]
async fn change_column_type_int_to_serial_succeeds() {
// ADR-0018 §8: int → serial joins the matrix. The
// column gains UNIQUE (since it's non-PK) and the
// existing values are preserved.
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
.await
.unwrap();
// Insert a few rows with explicit code values.
for (i, code) in [(1, 10), (2, 20), (3, 30)] {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string(), "code".to_string()]),
vec![
Value::Text(format!("row{i}")),
Value::Number(code.to_string()),
],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"code".to_string(),
Type::Serial,
ChangeColumnMode::Default,
None,
)
.await
.expect("int -> serial should succeed");
let code = result
.description
.columns
.iter()
.find(|c| c.name == "code")
.unwrap();
assert_eq!(code.user_type, Some(Type::Serial));
// No null cells, no transformation: no [client-side]
// note (storage-class change for int -> serial is
// identity at the value level).
assert!(
result.client_side.is_none(),
"int -> serial with all non-null values is a metadata-only change"
);
}
#[tokio::test]
async fn change_column_type_int_to_serial_refuses_duplicate_values() {
// Duplicate non-null values would violate the UNIQUE
// contract serial gains. Refused via the existing
// uniqueness-collision diagnostic.
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
.await
.unwrap();
// Two rows with the same code.
for i in 0..2 {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string(), "code".to_string()]),
vec![Value::Text(format!("row{i}")), Value::Number("7".to_string())],
None,
)
.await
.unwrap();
}
let err = db
.change_column_type(
"T".to_string(),
"code".to_string(),
Type::Serial,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("collision") || message.contains("uniqueness"),
"expected uniqueness diagnostic: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_text_to_serial_routes_via_int_hint() {
// Per ADR-0018 §8, only int → serial is supported
// directly. Other source types are refused with a hint
// to route through int.
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.change_column_type(
"T".to_string(),
"A".to_string(),
Type::Serial,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
match err {
DbError::Unsupported(message) => {
assert!(
message.contains("int"),
"expected hint about routing via int: {message}"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn change_column_type_int_to_serial_with_nulls_auto_fills() {
// Per ADR-0018 §3 / §7, null cells get sequence values
// continuing from MAX of non-null values. The
// [client-side] note reports the auto-fill count.
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
.await
.unwrap();
// Three rows: one with code=5, two with NULL.
db.insert(
"T".to_string(),
Some(vec!["Name".to_string(), "code".to_string()]),
vec![Value::Text("a".to_string()), Value::Number("5".to_string())],
None,
)
.await
.unwrap();
for n in ["b", "c"] {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text(n.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"code".to_string(),
Type::Serial,
ChangeColumnMode::Default,
None,
)
.await
.expect("int -> serial with auto-fill should succeed");
let note = result
.client_side
.expect("auto-fill should produce a client-side note");
assert_eq!(note.auto_filled, 2);
assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial));
// Confirm the filled values: existing 5, fills are 6
// and 7 (continue sequence from MAX+1).
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let code_idx = data.columns.iter().position(|c| c == "code").unwrap();
let mut values: Vec<i64> = data
.rows
.iter()
.filter_map(|r| r[code_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
.collect();
values.sort();
assert_eq!(values, vec![5, 6, 7]);
}
#[tokio::test]
async fn change_column_type_text_to_shortid_with_nulls_auto_fills() {
// text → shortid is in the matrix; null cells in the
// text column get fresh shortids (ADR-0018 §3).
let db = db();
make_table_with_n_rows(&db, "T", 0).await;
db.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::Text), None)
.await
.unwrap();
// One row with a valid shortid value, two with NULL.
db.insert(
"T".to_string(),
Some(vec!["Name".to_string(), "tag".to_string()]),
vec![
Value::Text("a".to_string()),
Value::Text("23456789Ab".to_string()),
],
None,
)
.await
.unwrap();
for n in ["b", "c"] {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text(n.to_string())],
None,
)
.await
.unwrap();
}
let result = db
.change_column_type(
"T".to_string(),
"tag".to_string(),
Type::ShortId,
ChangeColumnMode::Default,
None,
)
.await
.expect("text -> shortid with auto-fill should succeed");
let note = result
.client_side
.expect("auto-fill should produce a client-side note");
assert_eq!(note.auto_filled, 2);
assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId));
// All three rows now have valid shortids.
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
for row in &data.rows {
let v = row[tag_idx].as_ref().expect("non-null shortid after fill");
assert!(v.len() >= 10 && v.len() <= 12, "len out of range: {v}");
}
}
#[tokio::test]
async fn change_column_type_refuses_relationship_column() {
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["cust_id".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
let err = db
.change_column_type(
"Orders".to_string(),
"cust_id".to_string(),
Type::Text,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
let DbError::Unsupported(msg) = &err else {
panic!("expected Unsupported, got {err:?}");
};
// The refusal explains the FK link, not just that it failed
// (ADR-0035 Amendment 1, gap D).
assert!(
msg.contains("uses it as a foreign key"),
"explains the FK link; got: {msg}"
);
}
#[tokio::test]
async fn change_column_type_no_op_to_same_type_errors() {
let db = db();
make_id_table(&db, "T").await;
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Int), None)
.await
.unwrap();
let err = db
.change_column_type("T".to_string(), "A".to_string(), Type::Int, ChangeColumnMode::Default, None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn describe_missing_table_returns_no_such_table() {
let db = db();
let err = db.describe_table("Ghost".to_string(), None).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
// --- Relationship tests ---
async fn customers_orders_setup(db: &Database) {
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None)
.await
.unwrap();
}
#[tokio::test]
async fn add_relationship_appears_outbound_on_child() {
let db = db();
customers_orders_setup(&db).await;
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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_columns, vec!["CustId".to_string()]);
assert_eq!(rel.other_table, "Customers");
assert_eq!(rel.other_columns, vec!["id".to_string()]);
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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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_columns, vec!["id".to_string()]);
assert_eq!(rel.other_table, "Orders");
assert_eq!(rel.other_columns, vec!["CustId".to_string()]);
}
#[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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn add_relationship_with_type_mismatch_errors_with_advice() {
let db = db();
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap();
// Wrong type — text instead of int.
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap_err();
match err {
DbError::Unsupported(msg) => {
assert!(msg.contains("type mismatch"), "{msg}");
assert!(msg.contains("text") && msg.contains("int"), "{msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn add_relationship_against_non_pk_errors() {
let db = db();
// Customers has a non-PK column we'll try to point at.
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.add_column("Orders".to_string(), ColumnSpec::new("CustName".to_string(), Type::Text), None)
.await
.unwrap();
let err = db
.add_relationship(
None,
"Customers".to_string(),
vec!["Name".to_string()],
"Orders".to_string(),
vec!["CustName".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap_err();
match err {
DbError::Unsupported(msg) => assert!(msg.contains("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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
// Dropping the child is allowed (no inbound relationships
// on Orders) and cleans the metadata.
db.drop_table("Orders".to_string(), None).await.unwrap();
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
assert!(customers.inbound_relationships.is_empty());
}
#[tokio::test]
async fn add_relationship_with_duplicate_name_errors() {
let db = db();
customers_orders_setup(&db).await;
db.add_column("Orders".to_string(), ColumnSpec::new("OtherCust".to_string(), Type::Int), None)
.await
.unwrap();
db.add_relationship(
Some("dup".to_string()),
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
let err = db
.add_relationship(
Some("dup".to_string()),
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
// After the rebuild, the original columns are still
// present with the right user types, and any extra
// metadata (Name on Customers) survives.
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["id", "Name"]);
let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap();
assert_eq!(name_col.user_type, Some(Type::Text));
}
// --- Data operations (C5) ---
async fn customers_table(db: &Database) {
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
}
#[tokio::test]
async fn insert_short_form_skips_serial_column() {
let db = db();
customers_table(&db).await;
let result = db
.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
// The InsertResult itself carries the just-inserted row.
assert_eq!(result.data.rows.len(), 1);
assert_eq!(result.data.rows[0][1], Some("Alice".to_string()));
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]);
assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][1], Some("Alice".to_string()));
// id was auto-filled by SQLite.
assert_eq!(data.rows[0][0], Some("1".to_string()));
}
#[tokio::test]
async fn insert_short_form_auto_generates_shortid() {
let db = db();
db.create_table(
"Tags".to_string(),
vec![col("id", Type::ShortId), col("Label", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"Tags".to_string(),
None,
vec![Value::Text("database".to_string())],
None)
.await
.unwrap();
let data = db.query_data("Tags".to_string(), None, None, None).await.unwrap();
let id = data.rows[0][0].as_ref().expect("auto-generated id");
assert!(
id.len() >= 10 && id.len() <= 12,
"expected a base58 shortid, got {id}"
);
}
#[tokio::test]
async fn insert_explicit_columns_uses_user_values() {
let db = db();
customers_table(&db).await;
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string(), "Name".to_string()]),
vec![Value::Number("99".to_string()), Value::Text("Bob".to_string())],
None)
.await
.unwrap();
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][0], Some("99".to_string()));
assert_eq!(data.rows[0][1], Some("Bob".to_string()));
}
#[tokio::test]
async fn insert_with_type_mismatch_returns_invalid_value() {
let db = db();
customers_table(&db).await;
// 42 is a number, but Name expects text.
let err = db
.insert(
"Customers".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Number("42".to_string())],
None)
.await
.unwrap_err();
assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}");
}
#[tokio::test]
async fn update_with_where_affects_only_matching_rows() {
let db = db();
customers_table(&db).await;
for name in ["Alice", "Bob"] {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
None)
.await
.unwrap();
}
let result = db
.update(
"Customers".to_string(),
vec![("Name".to_string(), Value::Text("Alicia".to_string()))],
RowFilter::eq("id", Value::Number("1".to_string())),
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
// The UpdateResult contains only the updated rows.
assert_eq!(result.data.rows.len(), 1);
assert_eq!(result.data.rows[0][1], Some("Alicia".to_string()));
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][1], Some("Alicia".to_string()));
assert_eq!(data.rows[1][1], Some("Bob".to_string()));
}
// ---- Complex WHERE expressions (ADR-0026) ----------------
/// `People(id serial pk, Name text, Age int, Active bool)`
/// seeded with four rows: Alice/25/true, Bob/35/false,
/// Carol/45/true, Dave/35/true (ids 1..4).
async fn people_table(db: &Database) {
db.create_table(
"People".to_string(),
vec![
col("id", Type::Serial),
col("Name", Type::Text),
col("Age", Type::Int),
col("Active", Type::Bool),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
for (name, age, active) in [
("Alice", 25, true),
("Bob", 35, false),
("Carol", 45, true),
("Dave", 35, true),
] {
db.insert(
"People".to_string(),
None,
vec![
Value::Text(name.to_string()),
Value::Number(age.to_string()),
Value::Bool(active),
],
None,
)
.await
.unwrap();
}
}
/// Pull the `RowFilter` out of an `update` / `delete` parsed
/// from DSL — the readable way to build a complex `Expr`.
/// Parses in Simple mode: `update`/`delete` are shared entry
/// words since sub-phase 3j (ADR-0033 Amendment 3), so the DSL
/// `Command::Update`/`Delete` is only produced in Simple mode.
fn parse_filter(dsl: &str) -> RowFilter {
match crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
.expect("filter parse")
{
crate::dsl::command::Command::Update { filter, .. }
| crate::dsl::command::Command::Delete { filter, .. } => filter,
other => panic!("expected update/delete, got {other:?}"),
}
}
/// Pull the optional filter + limit out of a parsed
/// `show data` command.
fn parse_show(dsl: &str) -> (Option<Expr>, Option<u64>) {
match crate::dsl::parser::parse_command(dsl).expect("show parse") {
crate::dsl::command::Command::ShowData { filter, limit, .. } => {
(filter, limit)
}
other => panic!("expected show data, got {other:?}"),
}
}
/// The `Name` column of every remaining row, in row order.
fn names(data: &DataResult) -> Vec<String> {
data.rows
.iter()
.map(|r| r[1].clone().unwrap_or_default())
.collect()
}
#[tokio::test]
async fn delete_with_and_expression_filters_rows() {
let db = db();
people_table(&db).await;
let result = db
.delete(
"People".to_string(),
parse_filter(
"delete from People where Age >= 35 and Active = true",
),
None,
)
.await
.unwrap();
// Carol (45/true) and Dave (35/true) match; Bob (35) is
// inactive, Alice (25) is too young.
assert_eq!(result.rows_affected, 2);
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Alice", "Bob"]);
}
#[tokio::test]
async fn delete_with_or_expression_filters_rows() {
let db = db();
people_table(&db).await;
let result = db
.delete(
"People".to_string(),
parse_filter("delete from People where id = 1 or id = 3"),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 2);
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Bob", "Dave"]);
}
#[tokio::test]
async fn delete_with_not_expression_filters_rows() {
let db = db();
people_table(&db).await;
// `not Age = 35` keeps Bob and Dave (both 35).
let result = db
.delete(
"People".to_string(),
parse_filter("delete from People where not Age = 35"),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 2);
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Bob", "Dave"]);
}
#[tokio::test]
async fn delete_with_between_filters_rows() {
let db = db();
people_table(&db).await;
let result = db
.delete(
"People".to_string(),
parse_filter("delete from People where Age between 30 and 40"),
None,
)
.await
.unwrap();
// Bob (35) and Dave (35) are in range.
assert_eq!(result.rows_affected, 2);
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Alice", "Carol"]);
}
#[tokio::test]
async fn delete_with_in_filters_rows() {
let db = db();
people_table(&db).await;
let result = db
.delete(
"People".to_string(),
parse_filter(
"delete from People where Name in ('Alice', 'Carol')",
),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 2);
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Bob", "Dave"]);
}
#[tokio::test]
async fn delete_with_like_filters_rows() {
let db = db();
people_table(&db).await;
let result = db
.delete(
"People".to_string(),
parse_filter("delete from People where Name like 'A%'"),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 1, "only Alice matches `A%`");
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]);
}
#[tokio::test]
async fn update_with_complex_where_updates_only_matching_rows() {
let db = db();
people_table(&db).await;
// Deactivate everyone over 30 who is still active.
let result = db
.update(
"People".to_string(),
vec![("Active".to_string(), Value::Bool(false))],
parse_filter(
"update People set Active=false \
where Age > 30 and Active = true",
),
None,
)
.await
.unwrap();
// Carol (45) and Dave (35) — Bob is already inactive.
assert_eq!(result.rows_affected, 2);
}
#[tokio::test]
async fn query_data_with_where_filters_rows() {
let db = db();
people_table(&db).await;
let (filter, limit) = parse_show("show data People where Active = true");
let data = db
.query_data("People".to_string(), filter, limit, None)
.await
.unwrap();
assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]);
}
#[tokio::test]
async fn query_data_with_limit_caps_rows_by_primary_key() {
let db = db();
people_table(&db).await;
let (filter, limit) = parse_show("show data People limit 2");
let data = db
.query_data("People".to_string(), filter, limit, None)
.await
.unwrap();
// `limit` implies an ORDER BY the primary key, so this
// is a stable "first 2 by id".
assert_eq!(names(&data), vec!["Alice", "Bob"]);
}
#[tokio::test]
async fn query_data_with_where_and_limit_combines_both() {
let db = db();
people_table(&db).await;
let (filter, limit) =
parse_show("show data People where Age >= 35 limit 1");
let data = db
.query_data("People".to_string(), filter, limit, None)
.await
.unwrap();
// Three rows match `Age >= 35` (Bob, Carol, Dave); the
// limit keeps the first by primary key — Bob (id 2).
assert_eq!(names(&data), vec!["Bob"]);
}
// --- explain / query plans (ADR-0028) -------------------
/// Parse a non-`explain` query command for use as the inner
/// command of `explain_query_plan`. Simple mode: `explain`
/// wraps the DSL `show data` / `update` / `delete` commands
/// (ADR-0028; SQL DML is not explainable, ADR-0030 §13 OOS-2),
/// and `update`/`delete` only yield the DSL variant in Simple
/// mode (shared entry words since sub-phase 3j).
fn parse_inner(dsl: &str) -> Command {
crate::dsl::parser::parse_command_in_mode(dsl, crate::mode::Mode::Simple)
.expect("inner command parse")
}
/// Advanced-mode counterpart of `parse_inner` (ADR-0039): parses
/// a bare SQL command (the inner of an `explain`).
fn parse_inner_adv(sql: &str) -> Command {
crate::dsl::parser::parse_command_in_mode(sql, crate::mode::Mode::Advanced)
.expect("inner SQL command parse")
}
#[tokio::test]
async fn explain_show_data_returns_a_scan_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People where Active = true"))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a plan has at least one node");
// No index on `Active`, so the filtered query is a full
// table scan.
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"expected a SCAN node, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_show_data_uses_an_index_when_one_exists() {
let db = db();
people_table(&db).await;
db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
.await
.unwrap();
let plan = db
.explain_query_plan(parse_inner("show data People where Age = 35"))
.await
.unwrap();
// The teaching payoff: the plan flips from a scan to an
// index search once an index covers the WHERE column.
assert!(
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
"expected an index search, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_delete_does_not_remove_any_rows() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("delete from People where Active = true"))
.await
.unwrap();
assert!(!plan.rows.is_empty());
// ADR-0028 §1: EXPLAIN QUERY PLAN never executes.
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain delete must not delete");
}
#[tokio::test]
async fn explain_update_does_not_change_any_data() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner(
"update People set Active = false where Active = true",
))
.await
.unwrap();
let (filter, _) = parse_show("show data People where Active = true");
let data = db
.query_data("People".to_string(), filter, None, None)
.await
.unwrap();
// Alice, Carol, Dave are still active — nothing ran.
assert_eq!(data.rows.len(), 3, "explain update must not write");
}
#[tokio::test]
async fn explain_display_sql_inlines_literals_and_quotes_idents() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People where Name = 'Alice'"))
.await
.unwrap();
assert!(
plan.display_sql.contains("\"People\""),
"table identifier should be double-quoted: {}",
plan.display_sql,
);
assert!(
plan.display_sql.contains("'Alice'"),
"WHERE literal should be inlined: {}",
plan.display_sql,
);
assert!(
!plan.display_sql.contains('?'),
"display SQL must carry no `?` placeholders: {}",
plan.display_sql,
);
}
#[tokio::test]
async fn explain_display_sql_shows_the_implicit_order_by_for_limit() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People limit 2"))
.await
.unwrap();
// `limit` adds an implicit ORDER BY the primary key
// (ADR-0026 §5) — the display SQL surfaces it.
assert!(
plan.display_sql.contains("ORDER BY"),
"limit implies ORDER BY pk: {}",
plan.display_sql,
);
assert!(
plan.display_sql.contains("LIMIT 2"),
"limit count inlined: {}",
plan.display_sql,
);
}
#[tokio::test]
async fn explain_display_sql_writes_inequality_as_angle_brackets() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People where Age != 35"))
.await
.unwrap();
// ADR-0028 §3: inequality is shown as standard SQL `<>`
// even when the user typed `!=`.
assert!(
plan.display_sql.contains("<>"),
"inequality should render as `<>`: {}",
plan.display_sql,
);
assert!(
!plan.display_sql.contains("!="),
"the `!=` spelling should not survive: {}",
plan.display_sql,
);
}
#[tokio::test]
async fn explain_of_a_missing_table_is_an_error() {
let db = db();
let result = db
.explain_query_plan(parse_inner("show data NoSuchTable"))
.await;
assert!(result.is_err(), "explaining a missing table should fail");
}
// --- ADR-0039: explain over advanced-mode SQL -------------
#[tokio::test]
async fn explain_sql_select_returns_a_scan_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv("select * from People where Name = 'Bob'"))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a plan has at least one node");
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"expected a SCAN node, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_sql_select_uses_an_index_when_one_exists() {
let db = db();
people_table(&db).await;
db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
.await
.unwrap();
let plan = db
.explain_query_plan(parse_inner_adv("select * from People where Age = 35"))
.await
.unwrap();
assert!(
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
"expected an index search, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_sql_delete_does_not_remove_any_rows() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv("delete from People where Age = 35"))
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain delete must not delete");
}
#[tokio::test]
async fn explain_sql_update_does_not_change_any_data() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv(
"update People set Name = 'Zed' where Age = 35",
))
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert!(
!data.rows.iter().flatten().any(|c| c.as_deref() == Some("Zed")),
"explain update must not modify rows",
);
}
#[tokio::test]
async fn explain_sql_with_cte_returns_a_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv(
"with adults as (select * from People where Age = 35) select * from adults",
))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a WITH query has a plan");
}
#[tokio::test]
async fn explain_sql_insert_does_not_add_a_row() {
// EXPLAIN QUERY PLAN over an INSERT must not execute it
// (ADR-0039). A VALUES insert has a trivial plan (it may be
// empty), so we assert the call succeeds and the table is
// unchanged rather than asserting a node count.
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv(
"insert into People (Name, Age, Active) values ('Zed', 1, true)",
))
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain insert must not insert");
}
#[tokio::test]
async fn explain_sql_insert_from_select_returns_a_plan() {
// INSERT … SELECT has a real query plan (the SELECT source),
// so this exercises the non-trivial insert-plan path.
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv(
"insert into People (Name, Age, Active) select Name, Age, Active from People where Age = 35",
))
.await
.unwrap();
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"insert-from-select plans the SELECT source, got {:?}",
plan.rows,
);
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain insert-select must not insert");
}
#[tokio::test]
async fn explain_sql_display_sql_is_the_verbatim_query() {
// The SQL path carries the user's text grammar-as-text, so
// the display SQL is verbatim (no canonicalisation), unlike
// the DSL path which synthesises canonical SQL.
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv("select * from People where Age = 35"))
.await
.unwrap();
assert_eq!(plan.display_sql, "select * from People where Age = 35");
}
#[tokio::test]
async fn explain_sql_of_a_missing_table_is_an_error() {
let db = db();
let result = db
.explain_query_plan(parse_inner_adv("select * from NoSuchTable"))
.await;
assert!(result.is_err(), "explaining a missing SQL table should fail");
}
// --- column constraints at create-table (ADR-0029) ------
/// A `ColumnSpec` carrying the four constraint slots.
fn col_c(
name: &str,
ty: Type,
not_null: bool,
unique: bool,
default: Option<Value>,
) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
not_null,
unique,
default,
check: None,
default_sql: None,
check_sql: None,
}
}
/// Create `T(id serial pk, <extra>)`.
async fn table_with(db: &Database, extra: ColumnSpec) -> TableDescription {
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), extra],
vec!["id".to_string()],
None,
)
.await
.expect("create table")
}
#[tokio::test]
async fn create_table_not_null_column_rejects_a_null_insert() {
let db = db();
table_with(&db, col_c("name", Type::Text, true, false, None)).await;
let ok = db
.insert(
"T".to_string(),
Some(vec!["name".to_string()]),
vec![Value::Text("Alice".to_string())],
None,
)
.await;
assert!(ok.is_ok(), "a non-null value is accepted");
let bad = db
.insert(
"T".to_string(),
Some(vec!["name".to_string()]),
vec![Value::Null],
None,
)
.await;
assert!(bad.is_err(), "NULL into a NOT NULL column is refused");
}
#[tokio::test]
async fn create_table_unique_column_rejects_a_duplicate_insert() {
let db = db();
table_with(&db, col_c("email", Type::Text, false, true, None)).await;
let insert_email = |v: &str| {
db.insert(
"T".to_string(),
Some(vec!["email".to_string()]),
vec![Value::Text(v.to_string())],
None,
)
};
assert!(insert_email("a@x.io").await.is_ok());
assert!(
insert_email("a@x.io").await.is_err(),
"a duplicate value violates UNIQUE",
);
assert!(
insert_email("b@x.io").await.is_ok(),
"a distinct value is still accepted",
);
}
#[tokio::test]
async fn create_table_default_applies_when_the_column_is_omitted() {
let db = db();
db.create_table(
"T".to_string(),
vec![
col("id", Type::Serial),
col("name", Type::Text),
col_c("tier", Type::Int, false, false, Some(Value::Number("3".to_string()))),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
// Insert names only `name` — `tier` is omitted and so
// takes its DEFAULT; `id` auto-fills as a serial.
db.insert(
"T".to_string(),
Some(vec!["name".to_string()]),
vec![Value::Text("Alice".to_string())],
None,
)
.await
.unwrap();
let data = db
.query_data("T".to_string(), None, None, None)
.await
.unwrap();
let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap();
assert_eq!(
data.rows[0][tier_idx].as_deref(),
Some("3"),
"the omitted column took its DEFAULT",
);
}
#[tokio::test]
async fn describe_surfaces_the_column_constraints() {
let db = db();
let desc = table_with(
&db,
col_c(
"email",
Type::Text,
true,
true,
Some(Value::Text("none".to_string())),
),
)
.await;
let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
assert!(email.notnull, "NOT NULL is described");
assert!(email.unique, "UNIQUE is described");
assert_eq!(
email.default.as_deref(),
Some("'none'"),
"the DEFAULT literal is described",
);
}
#[tokio::test]
async fn rebuild_preserves_column_constraints() {
// A type change on one column rebuilds the whole table
// through `schema_to_ddl`; the constraints on the other
// columns must survive that round-trip.
let db = db();
db.create_table(
"T".to_string(),
vec![
col("id", Type::Serial),
col_c("email", Type::Text, true, true, None),
col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))),
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
// Change `tier`'s type — int -> decimal — forcing a rebuild.
db.change_column_type(
"T".to_string(),
"tier".to_string(),
Type::Decimal,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE");
let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
assert_eq!(
tier.default.as_deref(),
Some("1"),
"tier keeps its DEFAULT across the rebuild",
);
}
// --- column constraints at add-column (ADR-0029 §6) -----
#[tokio::test]
async fn add_column_with_default_fills_existing_rows() {
let db = db();
people_table(&db).await; // 4 rows
db.add_column(
"People".to_string(),
col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))),
None,
)
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
let idx = data.columns.iter().position(|c| c == "tier").unwrap();
assert!(
data.rows.iter().all(|r| r[idx].as_deref() == Some("1")),
"every existing row took the new column's DEFAULT",
);
}
#[tokio::test]
async fn add_not_null_column_without_default_to_populated_table_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c("x", Type::Int, true, false, None),
None,
)
.await;
assert!(
result.is_err(),
"a NOT NULL column with no default cannot be added to a table with rows",
);
}
#[tokio::test]
async fn add_not_null_column_without_default_to_empty_table_succeeds() {
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_column(
"T".to_string(),
col_c("x", Type::Int, true, false, None),
None,
)
.await
.expect("NOT NULL with no default is fine on an empty table");
let desc = db.describe_table("T".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull);
}
#[tokio::test]
async fn add_not_null_column_with_a_default_succeeds_on_a_populated_table() {
let db = db();
people_table(&db).await;
db.add_column(
"People".to_string(),
col_c("tier", Type::Int, true, false, Some(Value::Number("0".to_string()))),
None,
)
.await
.unwrap();
let desc = db.describe_table("People".to_string(), None).await.unwrap();
let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
assert!(tier.notnull);
assert_eq!(tier.default.as_deref(), Some("0"));
}
#[tokio::test]
async fn add_unique_column_applies_the_constraint_via_rebuild() {
let db = db();
people_table(&db).await; // 4 rows; the new column is all-NULL
db.add_column(
"People".to_string(),
col_c("badge", Type::Text, false, true, None),
None,
)
.await
.expect("a UNIQUE column with no default is fine — NULLs do not collide");
let desc = db.describe_table("People".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique);
}
#[tokio::test]
async fn add_unique_column_with_default_to_a_multi_row_table_is_refused() {
let db = db();
people_table(&db).await; // 4 rows
let result = db
.add_column(
"People".to_string(),
col_c("badge", Type::Text, false, true, Some(Value::Text("X".to_string()))),
None,
)
.await;
assert!(
result.is_err(),
"a UNIQUE column with a default would give every row the same value",
);
}
#[tokio::test]
async fn add_serial_column_with_a_default_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c("seq", Type::Serial, false, false, Some(Value::Number("1".to_string()))),
None,
)
.await;
assert!(
result.is_err(),
"a serial column auto-fills its own values — a default is ambiguous",
);
}
// --- CHECK constraints (ADR-0029 §4) --------------------
/// Parse a `create table` DSL string into its db-call parts
/// — the way to get a real `Expr` into a `ColumnSpec.check`
/// without hand-building the AST.
fn parse_create(dsl: &str) -> (String, Vec<ColumnSpec>, Vec<String>) {
match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
Command::CreateTable {
name,
columns,
primary_key,
} => (name, columns, primary_key),
other => panic!("expected CreateTable, got {other:?}"),
}
}
/// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
let (_, columns, _) = parse_create(&format!(
"create table __probe with pk {name}({}) check ({check_dsl})",
ty.keyword(),
));
columns.into_iter().next().expect("one column")
}
#[tokio::test]
async fn create_table_check_constraint_is_enforced() {
let db = db();
let (n, c, pk) = parse_create(
"create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
);
db.create_table(n, c, pk, None).await.unwrap();
let insert_grade = |g: &str| {
db.insert(
"Grades".to_string(),
Some(vec!["grade".to_string()]),
vec![Value::Text(g.to_string())],
None,
)
};
assert!(insert_grade("A").await.is_ok(), "a value the check allows");
assert!(
insert_grade("Z").await.is_err(),
"a value the check forbids is refused",
);
}
#[tokio::test]
async fn describe_surfaces_the_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk age(int) check (age >= 0)");
db.create_table(n, c, pk, None).await.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
let check = age.check.as_deref().expect("age carries a CHECK");
assert!(
check.contains(">="),
"the compiled check SQL is surfaced: {check}",
);
}
#[tokio::test]
async fn add_column_check_constraint_is_enforced() {
let db = db();
people_table(&db).await;
db.add_column(
"People".to_string(),
col_c_check("score", Type::Int, "score >= 0"),
None,
)
.await
.expect("a CHECK column adds via the rebuild path");
let desc = db.describe_table("People".to_string(), None).await.unwrap();
assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some());
// An update that violates the check is refused.
let bad = db
.update(
"People".to_string(),
vec![("score".to_string(), Value::Number("-1".to_string()))],
parse_filter("update People set score=-1 where id = 1"),
None,
)
.await;
assert!(bad.is_err(), "an update violating the CHECK is refused");
}
#[tokio::test]
async fn rebuild_preserves_a_check_constraint() {
let db = db();
let (n, c, pk) =
parse_create("create table T with pk code(text) check (code like 'X%')");
db.create_table(n, c, pk, None).await.unwrap();
db.add_column("T".to_string(), col("note", Type::Int), None)
.await
.unwrap();
// A type change on `note` rebuilds the table; `code`'s
// CHECK must survive the round-trip through schema_to_ddl.
db.change_column_type(
"T".to_string(),
"note".to_string(),
Type::Decimal,
ChangeColumnMode::Default,
None,
)
.await
.unwrap();
let desc = db.describe_table("T".to_string(), None).await.unwrap();
assert!(
desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(),
"code keeps its CHECK across the rebuild",
);
}
#[tokio::test]
async fn add_serial_column_with_a_check_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_column(
"People".to_string(),
col_c_check("seq", Type::Serial, "seq > 0"),
None,
)
.await;
assert!(
result.is_err(),
"a CHECK on an auto-generated column is a create-table-only feature",
);
}
#[tokio::test]
async fn update_with_all_rows_affects_everything() {
let db = db();
customers_table(&db).await;
for name in ["Alice", "Bob", "Carol"] {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
None)
.await
.unwrap();
}
let result = db
.update(
"Customers".to_string(),
vec![("Name".to_string(), Value::Text("X".to_string()))],
RowFilter::AllRows,
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 3);
assert_eq!(result.data.rows.len(), 3);
}
#[tokio::test]
async fn delete_with_where_removes_matching_rows() {
let db = db();
customers_table(&db).await;
for name in ["Alice", "Bob"] {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
None)
.await
.unwrap();
}
let result = db
.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
assert!(result.cascade.is_empty(), "no children to cascade to");
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][1], Some("Bob".to_string()));
}
#[tokio::test]
async fn fk_violation_returns_engine_classified_constraint_error() {
// Pre-H1 (ADR-0019), this test asserted that the
// engine's `FOREIGN KEY constraint failed` text was
// enriched in-band with a list of every relevant FK on
// the offending table. That enrichment moved into the
// friendly-error layer's catalog wording (see
// `friendly::translate::tests::fk_with_*_op_renders_*`)
// and out of the raw `DbError::Sqlite { message }`
// payload. What stays in `message` is the engine's
// un-enriched text — useful for the translator's own
// classification, but no longer the user-facing
// surface.
//
// This test now just confirms the engine error reaches
// us classified as a constraint violation; user-facing
// wording is exercised in the friendly module.
let db = db();
customers_table(&db).await;
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial), col("CustId", Type::Int)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["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(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
None)
.await
.unwrap();
db.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("1".to_string())],
None)
.await
.unwrap();
// Delete Alice — cascades to Orders.
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None)
.await
.unwrap();
let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap();
assert!(orders.rows.is_empty(), "child rows should be cascaded");
}
#[tokio::test]
async fn self_referential_cascade_counts_only_cascaded_rows() {
// A self-referential ON DELETE CASCADE FK (T.ParentId -> T.id):
// deleting the root of a chain cascades down within T. The
// directly-deleted root is reported in `rows_affected`, so the
// cascade summary must report only the *additional* rows
// removed by the self-reference — the raw before/after diff
// would double-count the direct delete. Parity fix shared with
// `do_sql_delete` (ADR-0033 Amendment 2 mechanism).
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Int), col("ParentId", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
Some("parent_of".to_string()),
"T".to_string(),
vec!["id".to_string()],
"T".to_string(),
vec!["ParentId".to_string()],
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Chain: 1 (root) <- 2 <- 3.
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("1".to_string()), Value::Null],
None,
)
.await
.unwrap();
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("2".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
db.insert(
"T".to_string(),
Some(vec!["id".to_string(), "ParentId".to_string()]),
vec![Value::Number("3".to_string()), Value::Number("2".to_string())],
None,
)
.await
.unwrap();
let result = db
.delete(
"T".to_string(),
RowFilter::eq("id", Value::Number("1".to_string())),
None,
)
.await
.unwrap();
assert_eq!(result.rows_affected, 1, "one row matched the filter directly");
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
assert_eq!(
result.cascade[0].rows_changed, 2,
"only the 2 cascaded rows, not the directly-deleted root too"
);
}
#[tokio::test]
async fn query_data_renders_bools_as_words() {
let db = db();
db.create_table(
"Flags".to_string(),
vec![col("id", Type::Serial), col("Active", Type::Bool)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(true)],
None)
.await
.unwrap();
db.insert(
"Flags".to_string(),
None,
vec![Value::Bool(false)],
None)
.await
.unwrap();
let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][1], Some("true".to_string()));
assert_eq!(data.rows[1][1], Some("false".to_string()));
}
#[tokio::test]
async fn query_data_renders_null_as_none() {
let db = db();
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("note", Type::Text)],
vec!["id".to_string()],
None)
.await
.unwrap();
db.insert(
"T".to_string(),
None,
vec![Value::Null],
None)
.await
.unwrap();
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
assert_eq!(data.rows[0][1], None);
}
// ---- ADR-0018 UNIQUE infrastructure ----
#[test]
fn read_schema_detects_single_column_unique() {
// Create a raw connection (no Database wrapper) so we
// can drop arbitrary DDL containing UNIQUE — the DSL
// doesn't yet expose UNIQUE as a user-controlled
// constraint (C3-track).
let conn = Connection::open_in_memory().unwrap();
configure_connection(&conn).unwrap();
conn.execute_batch(
"CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;",
)
.unwrap();
let schema = read_schema(&conn, "T").unwrap();
let id = schema.columns.iter().find(|c| c.name == "id").unwrap();
let code = schema.columns.iter().find(|c| c.name == "code").unwrap();
assert!(id.primary_key, "id should still be PK");
assert!(
!id.unique,
"PK columns are not separately marked unique \
(PK already implies it; double-marking would lead to \
redundant DDL)"
);
assert!(code.unique, "code should be detected as unique");
}
#[test]
fn read_schema_ignores_compound_unique_constraints() {
// Multi-column UNIQUE is out of scope for ADR-0018; the
// detection helper filters it out so schema_to_ddl does
// not accidentally emit a column-level UNIQUE for one
// half of a compound constraint.
let conn = Connection::open_in_memory().unwrap();
configure_connection(&conn).unwrap();
conn.execute_batch(
"CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT, UNIQUE(a, b)) STRICT;",
)
.unwrap();
let schema = read_schema(&conn, "T").unwrap();
for col in &schema.columns {
assert!(
!col.unique,
"column {} should not be marked unique under compound UNIQUE",
col.name
);
}
}
#[test]
fn schema_to_ddl_emits_unique_for_marked_column() {
// Construct a ReadSchema with a unique non-PK column and
// confirm the round-trip through schema_to_ddl produces
// valid DDL containing UNIQUE.
let schema = ReadSchema {
columns: vec![
ReadColumn {
name: "id".to_string(),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default_sql: None,
check: None,
user_type: Some(Type::Serial),
},
ReadColumn {
name: "code".to_string(),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
unique: true,
default_sql: None,
check: None,
user_type: Some(Type::Text),
},
],
primary_key: vec!["id".to_string()],
foreign_keys: vec![],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let ddl = schema_to_ddl("T", &schema);
assert!(
ddl.contains("\"id\" INTEGER PRIMARY KEY"),
"PK emission preserved: {ddl}"
);
assert!(
ddl.contains("\"code\" TEXT UNIQUE"),
"UNIQUE emitted on non-PK column: {ddl}"
);
assert!(
!ddl.contains("\"id\" INTEGER PRIMARY KEY UNIQUE"),
"PK column should not double-emit UNIQUE: {ddl}"
);
// The DDL is valid SQL — apply it and read it back.
let conn = Connection::open_in_memory().unwrap();
configure_connection(&conn).unwrap();
conn.execute_batch(&ddl).unwrap();
let round = read_schema(&conn, "T").unwrap();
let code = round.columns.iter().find(|c| c.name == "code").unwrap();
assert!(code.unique, "round-tripped column retains unique flag");
}
#[tokio::test]
async fn unique_flag_persisted_in_yaml_for_non_pk_serial() {
// ADR-0018 §4: a non-PK serial column gains UNIQUE,
// and the flag must be recorded in `project.yaml` so
// that a rebuild-from-text reconstructs the constraint
// faithfully (preserves the "serial -> int leaves
// UNIQUE in place" promise across save/load cycles).
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf());
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
// Read the persisted YAML straight from disk.
let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml.contains("seq") && yaml.contains("unique: true"),
"yaml should record the unique flag for `seq`: {yaml}"
);
// PK column not separately marked unique — PK already
// implies UNIQUE, double-marking would emit redundant
// DDL on rebuild.
let lines_with_id: Vec<&str> = yaml
.lines()
.filter(|l| l.contains("name: id"))
.collect();
for line in &lines_with_id {
assert!(
!line.contains("unique: true"),
"PK column should not be marked unique in YAML: {line}"
);
}
}
#[tokio::test]
async fn set_mode_persists_and_survives_a_later_ddl_command() {
// ADR-0015 mode-restore amendment (issue #14): the worker
// stamps the *current* input mode into `project.yaml` on
// every write, so a mode change is saved immediately AND a
// later schema-mutating command re-writes the same live
// mode rather than clobbering it back to the default. This
// is the core guarantee the whole feature rests on.
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();
// A table exists first so there is a schema to rewrite.
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
// Switch to advanced and confirm it lands in the file.
db.set_mode(Mode::Advanced).await.unwrap();
let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml.contains("mode: advanced"),
"set_mode should record the mode in project.yaml: {yaml}"
);
// A later DDL command rewrites the whole project.yaml —
// the mode must NOT regress to the default.
db.add_column(
"T".to_string(),
ColumnSpec::new("extra".to_string(), Type::Text),
None,
)
.await
.unwrap();
let yaml_after = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml_after.contains("mode: advanced"),
"a later DDL command must preserve the live mode, not clobber it: {yaml_after}"
);
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"the stored mode reads back as advanced"
);
}
#[tokio::test]
async fn set_mode_persists_even_with_no_prior_command() {
// ADR-0015 mode-restore amendment (issue #14), persist on
// unload: leaving a project must record the mode even if the
// session ran NO command (e.g. a bare `--mode advanced` plus
// read-only browsing). The unload calls `set_mode`, which
// writes project.yaml from the empty schema + the live mode.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf()).with_mode(Mode::Advanced);
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
// No command runs — straight to the unload-style persist.
db.set_mode(Mode::Advanced).await.unwrap();
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"an unload persists the mode with no prior command",
);
}
#[tokio::test]
async fn unique_flag_round_trips_through_rebuild() {
// End-to-end: create a table with a non-PK serial,
// then rebuild from text. The rebuild should re-emit
// UNIQUE on the column, restoring the constraint
// exactly as the original.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf());
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
.await
.unwrap();
// Tear down the .db file and rebuild from yaml + csvs.
drop(db);
std::fs::remove_file(&db_path).unwrap();
let persistence2 = Persistence::new(dir.path().to_path_buf());
let db = Database::open_with_persistence(&db_path, persistence2).unwrap();
db.rebuild_from_text(dir.path().to_path_buf(), None)
.await
.unwrap();
// Read the schema and confirm `seq` is still unique.
let desc = db.describe_table("T".to_string(), None).await.unwrap();
let seq = desc
.columns
.iter()
.find(|c| c.name == "seq")
.expect("seq column rebuilt");
assert_eq!(seq.user_type, Some(Type::Serial));
// Confirm the UNIQUE constraint is enforced post-
// rebuild by attempting to UPDATE all rows to the same
// value... but the table is empty. Instead, insert two
// rows and then UPDATE.
for n in ["a", "b"] {
db.insert(
"T".to_string(),
Some(vec!["Name".to_string()]),
vec![Value::Text(n.to_string())],
None,
)
.await
.unwrap();
}
let err = db
.update(
"T".to_string(),
vec![("seq".to_string(), Value::Number("1".to_string()))],
RowFilter::AllRows,
None,
)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => {
assert_eq!(kind, SqliteErrorKind::UniqueViolation);
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn quoted_table_names_round_trip() {
let db = db();
// Identifier with internal whitespace would not parse via the DSL
// today, but the DB layer should still handle it correctly.
db.create_table(
"Order Lines".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None)
.await
.unwrap();
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Order Lines".to_string()]);
let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap();
assert_eq!(desc.name, "Order Lines");
}
// ---- list_names_for (ADR-0022 §9, stage 7) ----
#[tokio::test]
async fn list_names_for_new_name_short_circuits_to_empty() {
// The user invents new names; schema has nothing to
// offer. The public method short-circuits before
// touching the worker.
let db = db();
let names = db
.list_names_for(crate::dsl::grammar::IdentSource::NewName)
.await
.unwrap();
assert!(names.is_empty());
}
#[tokio::test]
async fn list_names_for_table_returns_user_tables_alphabetised() {
let db = db();
make_id_table(&db, "Customers").await;
make_id_table(&db, "Orders").await;
let names = db
.list_names_for(crate::dsl::grammar::IdentSource::Tables)
.await
.unwrap();
assert_eq!(names, vec!["Customers".to_string(), "Orders".to_string()]);
}
#[tokio::test]
async fn list_names_for_table_filters_internal_metadata_tables() {
// The internal __rdbms_* tables must never appear in
// the completion menu (ADR-0002 user-facing posture).
let db = db();
make_id_table(&db, "Customers").await;
let names = db
.list_names_for(crate::dsl::grammar::IdentSource::Tables)
.await
.unwrap();
assert_eq!(names, vec!["Customers".to_string()]);
for n in &names {
assert!(
!n.starts_with("__rdbms_"),
"internal table leaked into completion: {n}",
);
}
}
#[tokio::test]
async fn list_names_for_column_returns_distinct_columns_alphabetised() {
let db = db();
// Two tables sharing a column name `id`, with different
// additional columns.
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial), col("name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial), col("total", Type::Real)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
let names = db
.list_names_for(crate::dsl::grammar::IdentSource::Columns)
.await
.unwrap();
// `id` appears once despite being in both tables (DISTINCT).
assert_eq!(
names,
vec!["id".to_string(), "name".to_string(), "total".to_string()],
);
}
#[tokio::test]
async fn list_names_for_relationship_returns_named_relationships() {
let db = db();
db.create_table(
"Customers".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![col("id", Type::Serial), col("CustId", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
Some("cust_orders".to_string()),
"Customers".to_string(),
vec!["id".to_string()],
"Orders".to_string(),
vec!["CustId".to_string()],
crate::dsl::ReferentialAction::NoAction,
crate::dsl::ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
let names = db
.list_names_for(crate::dsl::grammar::IdentSource::Relationships)
.await
.unwrap();
assert_eq!(names, vec!["cust_orders".to_string()]);
}
// --- add constraint / drop constraint (ADR-0029 §2.2) -----
/// A `Constraint::Check` whose expression references
/// `column` of type `ty`, parsed from `check_dsl`.
fn check_constraint(column: &str, ty: Type, check_dsl: &str) -> Constraint {
Constraint::Check(
col_c_check(column, ty, check_dsl)
.check
.expect("the CHECK expression parses"),
)
}
/// `People` plus an all-NULL plain `int` column `x`.
async fn people_with_null_column(db: &Database) {
people_table(db).await;
db.add_column("People".to_string(), col("x", Type::Int), None)
.await
.expect("a plain nullable column adds");
}
#[tokio::test]
async fn add_constraint_not_null_succeeds_on_clean_column() {
let db = db();
people_table(&db).await; // every row has a non-null Name
let desc = db
.add_constraint(
"People".to_string(),
"Name".to_string(),
Constraint::NotNull,
None,
)
.await
.expect("NOT NULL applies — no row holds a null");
assert!(
desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull,
"the column is now NOT NULL",
);
}
#[tokio::test]
async fn add_constraint_not_null_refused_when_rows_are_null() {
let db = db();
people_with_null_column(&db).await; // `x` is null in every row
let result = db
.add_constraint("People".to_string(), "x".to_string(), Constraint::NotNull, None)
.await;
let err = result.expect_err("NOT NULL is refused — rows hold null");
assert!(
format!("{err}").contains("null"),
"the refusal explains the null rows: {err}",
);
}
#[tokio::test]
async fn add_constraint_unique_succeeds_on_distinct_values() {
let db = db();
people_table(&db).await; // Names are all distinct
let desc = db
.add_constraint(
"People".to_string(),
"Name".to_string(),
Constraint::Unique,
None,
)
.await
.expect("UNIQUE applies — every Name is distinct");
assert!(desc.columns.iter().find(|c| c.name == "Name").unwrap().unique);
}
#[tokio::test]
async fn add_constraint_unique_refused_on_duplicate_values() {
let db = db();
people_table(&db).await; // Age 35 appears for Bob and Dave
let result = db
.add_constraint("People".to_string(), "Age".to_string(), Constraint::Unique, None)
.await;
assert!(
result.is_err(),
"UNIQUE is refused — the value 35 appears in two rows",
);
}
#[tokio::test]
async fn add_constraint_check_succeeds_when_data_satisfies_it() {
let db = db();
people_table(&db).await; // every Age is >= 0
let desc = db
.add_constraint(
"People".to_string(),
"Age".to_string(),
check_constraint("Age", Type::Int, "Age >= 0"),
None,
)
.await
.expect("the CHECK applies — all rows satisfy it");
assert!(desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_some());
}
#[tokio::test]
async fn add_constraint_check_refused_when_rows_violate_it() {
let db = db();
people_table(&db).await; // Alice is 25, Bob/Dave are 35
let result = db
.add_constraint(
"People".to_string(),
"Age".to_string(),
check_constraint("Age", Type::Int, "Age >= 40"),
None,
)
.await;
assert!(result.is_err(), "the CHECK is refused — three rows are under 40");
}
#[tokio::test]
async fn add_constraint_default_succeeds() {
let db = db();
people_table(&db).await;
let desc = db
.add_constraint(
"People".to_string(),
"Age".to_string(),
Constraint::Default(Value::Number("0".to_string())),
None,
)
.await
.expect("a DEFAULT applies — it never touches existing rows");
assert_eq!(
desc.columns.iter().find(|c| c.name == "Age").unwrap().default.as_deref(),
Some("0"),
);
}
#[tokio::test]
async fn add_constraint_check_is_enforced_after_being_added() {
let db = db();
people_table(&db).await;
db.add_constraint(
"People".to_string(),
"Age".to_string(),
check_constraint("Age", Type::Int, "Age >= 0"),
None,
)
.await
.unwrap();
let bad = db
.insert(
"People".to_string(),
None,
vec![
Value::Text("Eve".to_string()),
Value::Number("-1".to_string()),
Value::Bool(true),
],
None,
)
.await;
assert!(bad.is_err(), "an insert violating the new CHECK is refused");
}
#[tokio::test]
async fn add_constraint_not_null_on_pk_column_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_constraint("People".to_string(), "id".to_string(), Constraint::NotNull, None)
.await;
assert!(result.is_err(), "a PK column is already NOT NULL (ADR-0029 §9)");
}
#[tokio::test]
async fn add_constraint_unique_on_single_column_pk_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_constraint("People".to_string(), "id".to_string(), Constraint::Unique, None)
.await;
assert!(
result.is_err(),
"a single-column PK is already UNIQUE (ADR-0029 §9)",
);
}
#[tokio::test]
async fn add_constraint_unique_on_compound_pk_member_is_allowed() {
// A compound PK does not make its members individually
// unique, so `unique` on one of them is meaningful.
let db = db();
db.create_table(
"T".to_string(),
vec![col("a", Type::Int), col("b", Type::Int)],
vec!["a".to_string(), "b".to_string()],
None,
)
.await
.unwrap();
let desc = db
.add_constraint("T".to_string(), "a".to_string(), Constraint::Unique, None)
.await
.expect("UNIQUE on a compound-PK member is allowed");
assert!(desc.columns.iter().find(|c| c.name == "a").unwrap().unique);
}
#[tokio::test]
async fn add_constraint_default_on_serial_column_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.add_constraint(
"People".to_string(),
"id".to_string(),
Constraint::Default(Value::Number("5".to_string())),
None,
)
.await;
assert!(
result.is_err(),
"a serial column auto-fills its own values (ADR-0029 §6)",
);
}
#[tokio::test]
async fn add_constraint_to_missing_column_errors() {
let db = db();
people_table(&db).await;
let result = db
.add_constraint("People".to_string(), "ghost".to_string(), Constraint::Unique, None)
.await;
assert!(result.is_err(), "no such column");
}
#[tokio::test]
async fn drop_constraint_removes_not_null() {
let db = db();
people_table(&db).await;
db.add_constraint(
"People".to_string(),
"Name".to_string(),
Constraint::NotNull,
None,
)
.await
.unwrap();
let desc = db
.drop_constraint(
"People".to_string(),
"Name".to_string(),
ConstraintKind::NotNull,
None,
)
.await
.expect("the NOT NULL is dropped");
assert!(
!desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull,
"the column is nullable again",
);
}
#[tokio::test]
async fn drop_constraint_check_clears_the_constraint() {
let db = db();
people_table(&db).await;
db.add_constraint(
"People".to_string(),
"Age".to_string(),
check_constraint("Age", Type::Int, "Age >= 0"),
None,
)
.await
.unwrap();
let desc = db
.drop_constraint(
"People".to_string(),
"Age".to_string(),
ConstraintKind::Check,
None,
)
.await
.expect("the CHECK is dropped");
assert!(
desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_none(),
"the CHECK is gone from the structure view",
);
// With the CHECK gone, a previously-forbidden value inserts.
db.insert(
"People".to_string(),
None,
vec![
Value::Text("Eve".to_string()),
Value::Number("-1".to_string()),
Value::Bool(true),
],
None,
)
.await
.expect("the CHECK no longer applies");
}
#[tokio::test]
async fn drop_constraint_not_null_on_pk_is_refused() {
let db = db();
people_table(&db).await;
let result = db
.drop_constraint(
"People".to_string(),
"id".to_string(),
ConstraintKind::NotNull,
None,
)
.await;
assert!(
result.is_err(),
"the PK still enforces NOT NULL — nothing to drop (ADR-0029 §9)",
);
}
#[tokio::test]
async fn drop_constraint_absent_constraint_is_refused() {
let db = db();
people_table(&db).await; // `Name` carries no UNIQUE
let result = db
.drop_constraint(
"People".to_string(),
"Name".to_string(),
ConstraintKind::Unique,
None,
)
.await;
assert!(
result.is_err(),
"dropping a constraint the column never had is a friendly refusal",
);
}
}