62f09bebc5
A self-referential ON DELETE CASCADE FK (e.g. T.ParentId -> T.id) is returned by read_relationships_inbound as a child whose table IS the delete target. The before/after row-count diff then includes the directly-deleted rows (already in rows_affected), so deleting a chain root reported 3 cascaded rows when only 2 were removed via the self-reference. Fix in both do_delete (DSL) and do_sql_delete (SQL): when the child table equals the target, subtract rows_affected from the diff and guard on the corrected count (a leaf delete no longer reports a phantom 0-row self-cascade); the target's CSV is already queued, so a self-ref child is not re-added to rewritten_tables. Pre-existing in do_delete; surfaced by the 3f DA pass, fixed in both paths to keep DSL/SQL parity. Behaviour: report only the rows removed via the self-reference (user-confirmed). Also adds an app-level render test for the SQL DELETE path (handle_dsl_delete_success via CommandOutcome::Delete) — the shared renderer's ok-summary + per-relationship cascade line were exercised only through the DSL path before. Test-first: self_referential_cascade_counts_only_cascaded_rows added for both paths (asserted 2, failed at 3 before the fix). 1545 pass / 0 fail / 1 ignored. Clippy clean.
11180 lines
380 KiB
Rust
11180 lines
380 KiB
Rust
//! SQLite database access via an async worker.
|
|
//!
|
|
//! The application talks to SQLite through a single
|
|
//! request/response channel. A dedicated OS thread owns the
|
|
//! `rusqlite::Connection` (which is `Send` but `!Sync` and uses
|
|
//! a synchronous API), receives `Request` messages, and replies
|
|
//! on per-request `oneshot` channels.
|
|
//!
|
|
//! This shape was chosen up front in Phase 2 of the parser/DB
|
|
//! iteration so that B3 (query timeout/cancellation) and U1
|
|
//! (snapshot capture) drop in without an architectural refactor.
|
|
//!
|
|
//! ## STRICT and foreign keys
|
|
//!
|
|
//! Per ADR-0002, every table is created with the `STRICT`
|
|
//! keyword and the connection-level `PRAGMA foreign_keys` is
|
|
//! enabled at open time.
|
|
//!
|
|
//! ## Error handling
|
|
//!
|
|
//! Database errors flow through `DbError`, which carries a
|
|
//! coarse `kind` to support the future friendly-error layer
|
|
//! (H1). For now `friendly_message()` is a passthrough; when H1
|
|
//! lands the body of that method becomes the translation table.
|
|
|
|
use std::path::Path;
|
|
use std::thread;
|
|
|
|
use rusqlite::Connection;
|
|
use tokio::sync::{mpsc, oneshot};
|
|
use tracing::{debug, info};
|
|
|
|
use crate::dsl::action::ReferentialAction;
|
|
use crate::dsl::command::{
|
|
ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector,
|
|
Operand, Predicate, RelationshipSelector, RowFilter,
|
|
};
|
|
use crate::dsl::ColumnSpec;
|
|
use crate::dsl::shortid;
|
|
use crate::dsl::types::Type;
|
|
use crate::dsl::value::{Bound, Value, ValueError};
|
|
use crate::output_render::{Alignment, render_diagnostic_table};
|
|
use crate::type_change;
|
|
use crate::persistence::{
|
|
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
|
|
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
|
};
|
|
use crate::project::{DATA_DIR, PROJECT_YAML};
|
|
|
|
/// Inbox capacity. The worker is fast enough that this rarely
|
|
/// matters; `64` is a generous head-room for bursts.
|
|
const REQUEST_CHANNEL_CAPACITY: usize = 64;
|
|
|
|
/// In-process handle for the database. Cheap to clone.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Database {
|
|
inbox: mpsc::Sender<Request>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TableDescription {
|
|
pub name: String,
|
|
pub columns: Vec<ColumnDescription>,
|
|
/// Relationships where *this* table is the child (holds the
|
|
/// FK column referencing another table).
|
|
pub outbound_relationships: Vec<RelationshipEnd>,
|
|
/// Relationships where *this* table is the parent (some
|
|
/// other table's column references one of ours).
|
|
pub inbound_relationships: Vec<RelationshipEnd>,
|
|
/// User-created indexes on this table (ADR-0025).
|
|
pub indexes: Vec<IndexInfo>,
|
|
}
|
|
|
|
/// One user-created index on a table (ADR-0025).
|
|
///
|
|
/// Read live from the engine's native catalog
|
|
/// (`pragma_index_list` / `pragma_index_info`); the playground
|
|
/// keeps no separate index metadata table. Only indexes with
|
|
/// origin `c` (a `CREATE INDEX` statement) are surfaced — the
|
|
/// automatic indexes backing primary keys and UNIQUE
|
|
/// constraints are not user indexes.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct IndexInfo {
|
|
pub name: String,
|
|
/// Indexed columns, in index order.
|
|
pub columns: Vec<String>,
|
|
pub unique: bool,
|
|
}
|
|
|
|
/// One end of a relationship as seen from the table being
|
|
/// described.
|
|
///
|
|
/// Used for both outbound (this table is the child, holding the
|
|
/// FK column) and inbound (this table is the parent being
|
|
/// referenced) sides; the field meanings flip per side.
|
|
/// `(outbound, inbound)` pair returned by
|
|
/// [`Database::read_relationships`].
|
|
pub type RelationshipsReply =
|
|
Result<(Vec<RelationshipEnd>, Vec<RelationshipEnd>), DbError>;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RelationshipEnd {
|
|
/// User-facing name of the relationship (auto-generated or
|
|
/// user-supplied at creation time).
|
|
pub name: String,
|
|
/// The other table involved.
|
|
pub other_table: String,
|
|
/// The column on the other table.
|
|
pub other_column: String,
|
|
/// The column on *this* table.
|
|
pub local_column: String,
|
|
pub on_delete: ReferentialAction,
|
|
pub on_update: ReferentialAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ColumnDescription {
|
|
pub name: String,
|
|
/// The user-facing type the column was declared as, recovered
|
|
/// from our internal column-metadata table. Always populated
|
|
/// for tables created through the DSL. `None` only for the
|
|
/// edge case of a foreign-attached database whose tables we
|
|
/// did not create — not achievable in the current flow.
|
|
pub user_type: Option<Type>,
|
|
/// The SQLite-side type as reported by `PRAGMA table_info`
|
|
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
|
|
/// fall-back when `user_type` is not available; the UI
|
|
/// prefers `user_type` when rendering.
|
|
pub sqlite_type: String,
|
|
pub notnull: bool,
|
|
pub primary_key: bool,
|
|
/// Carries a single-column `UNIQUE` constraint (ADR-0029).
|
|
/// A PK column is not flagged here — the `primary_key`
|
|
/// flag already conveys its implicit uniqueness.
|
|
pub unique: bool,
|
|
/// The column's `DEFAULT` expression as SQLite reports it,
|
|
/// or `None` (ADR-0029).
|
|
pub default: Option<String>,
|
|
/// The column's `CHECK` constraint in compiled-SQL form,
|
|
/// or `None` (ADR-0029).
|
|
pub check: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DbError {
|
|
Sqlite {
|
|
message: String,
|
|
kind: SqliteErrorKind,
|
|
},
|
|
Unsupported(String),
|
|
InvalidValue(String),
|
|
PersistenceFatal {
|
|
operation: &'static str,
|
|
path: std::path::PathBuf,
|
|
message: String,
|
|
},
|
|
RebuildRowFailed {
|
|
table: String,
|
|
csv_path: std::path::PathBuf,
|
|
row_number: usize,
|
|
detail: String,
|
|
},
|
|
WorkerGone,
|
|
Io(String),
|
|
}
|
|
|
|
impl std::fmt::Display for DbError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Sqlite { message, .. } => f.write_str(&crate::t!(
|
|
"db.error.sqlite",
|
|
message = message,
|
|
)),
|
|
Self::Unsupported(detail) => f.write_str(&crate::t!(
|
|
"db.error.unsupported",
|
|
detail = detail,
|
|
)),
|
|
Self::InvalidValue(detail) => f.write_str(&crate::t!(
|
|
"db.error.invalid_value",
|
|
detail = detail,
|
|
)),
|
|
Self::PersistenceFatal {
|
|
operation,
|
|
path,
|
|
message,
|
|
} => f.write_str(&crate::t!(
|
|
"db.error.persistence_fatal",
|
|
operation = operation,
|
|
path = path.display(),
|
|
message = message,
|
|
)),
|
|
Self::RebuildRowFailed {
|
|
table,
|
|
csv_path,
|
|
row_number,
|
|
detail,
|
|
} => f.write_str(&crate::t!(
|
|
"db.error.rebuild_row_failed",
|
|
row_number = row_number,
|
|
csv_path = csv_path.display(),
|
|
table = table,
|
|
detail = detail,
|
|
)),
|
|
Self::WorkerGone => f.write_str(&crate::t!("db.error.worker_gone")),
|
|
Self::Io(detail) => f.write_str(&crate::t!(
|
|
"db.error.io",
|
|
detail = detail,
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DbError {}
|
|
|
|
/// Result of a query / show-data call.
|
|
///
|
|
/// `None` cells render as NULL; `Some(s)` renders as the
|
|
/// string. `column_types` carries the user-facing type per
|
|
/// column (per ADR-0016 §2): the renderer uses it for
|
|
/// alignment, and future work uses it for type-aware cell
|
|
/// styling. `None` only for the edge case of a
|
|
/// foreign-attached database we did not create — not
|
|
/// achievable in normal use.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DataResult {
|
|
pub table_name: String,
|
|
pub columns: Vec<String>,
|
|
pub column_types: Vec<Option<Type>>,
|
|
pub rows: Vec<Vec<Option<String>>>,
|
|
}
|
|
|
|
/// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2).
|
|
///
|
|
/// `id` / `parent` form the plan tree (`parent == 0` is a
|
|
/// top-level node); `detail` is the engine's verbatim step
|
|
/// description (`SCAN Customers`, `SEARCH … USING INDEX …`).
|
|
/// The `notused` column the engine also returns is dropped.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ExplainRow {
|
|
pub id: i64,
|
|
pub parent: i64,
|
|
pub detail: String,
|
|
}
|
|
|
|
/// A captured query plan (ADR-0028 §2/§3).
|
|
///
|
|
/// `display_sql` is the standard-SQL form of the explained
|
|
/// statement — shown above the plan tree as part of the
|
|
/// simple → advanced bridge. `rows` are the plan-tree nodes.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct QueryPlan {
|
|
pub display_sql: String,
|
|
pub rows: Vec<ExplainRow>,
|
|
}
|
|
|
|
/// Outcome of a successful INSERT — a count plus the new row(s)
|
|
/// fetched immediately after so the user can see what landed
|
|
/// (auto-filled IDs, generated shortids, etc.).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InsertResult {
|
|
pub rows_affected: usize,
|
|
pub data: DataResult,
|
|
}
|
|
|
|
/// Outcome of a successful `add column …`.
|
|
///
|
|
/// Carries the post-add structure (used for the auto-show that
|
|
/// follows DDL) plus zero or one pre-rendered `[client-side]`
|
|
/// note lines (ADR-0018 §9). Only the auto-fill paths
|
|
/// (`add column T: x (serial|shortid)` on a non-empty table)
|
|
/// produce notes.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AddColumnResult {
|
|
pub description: TableDescription,
|
|
pub client_side_notes: Vec<String>,
|
|
}
|
|
|
|
/// Outcome of a successful `drop column …` (ADR-0025).
|
|
///
|
|
/// `dropped_indexes` names any index removed by `--cascade`
|
|
/// because it covered the dropped column. Empty in the common
|
|
/// case (no covering index, or none to cascade); the runtime
|
|
/// renders one note line per entry.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DropColumnResult {
|
|
pub description: TableDescription,
|
|
pub dropped_indexes: Vec<String>,
|
|
}
|
|
|
|
/// Outcome of a successful `change column …` (ADR-0017 §6).
|
|
///
|
|
/// `description` is the post-rebuild table structure (used for
|
|
/// the auto-show that follows DDL). `client_side` carries the
|
|
/// pedagogical note that fires whenever the playground rewrote
|
|
/// any cell value before storing it — the moment that tells the
|
|
/// learner "the tool did this for you; raw SQL would need a
|
|
/// `CAST` or application code." `None` when the change was
|
|
/// pure metadata (storage class unchanged for every row).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ChangeColumnTypeResult {
|
|
pub description: TableDescription,
|
|
pub client_side: Option<ClientSideNote>,
|
|
}
|
|
|
|
/// Counts feeding the `[client-side]` success line.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ClientSideNote {
|
|
/// Cells whose stored value differs from what the user
|
|
/// originally inserted (any non-identity transformation,
|
|
/// including pure storage-class changes like
|
|
/// `Text("42")` → `Integer(42)`). Auto-filled cells (per
|
|
/// ADR-0018) are NOT counted here — they're tracked in
|
|
/// `auto_filled` separately so the success summary can
|
|
/// distinguish "the tool transformed your data" from "the
|
|
/// tool generated values where you had nulls."
|
|
pub transformed: usize,
|
|
/// Subset of `transformed` where information was discarded
|
|
/// (only non-zero when `--force-conversion` was used).
|
|
pub lossy: usize,
|
|
/// Null cells filled with auto-generated values when the
|
|
/// target type carries an auto-generation contract
|
|
/// (`serial` / `shortid`). ADR-0018 §3 / §7.
|
|
pub auto_filled: usize,
|
|
/// What kind of value was auto-generated; drives the note
|
|
/// wording (per ADR-0018 §9). `None` when `auto_filled` is
|
|
/// zero.
|
|
pub auto_fill_kind: Option<AutoFillKind>,
|
|
}
|
|
|
|
/// Whether an auto-fill emitted serial sequence values or
|
|
/// generated shortids.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AutoFillKind {
|
|
Serial,
|
|
ShortId,
|
|
}
|
|
|
|
/// Outcome of a successful UPDATE — a count plus the rows that
|
|
/// matched (and were updated). Captured by rowid so that even an
|
|
/// UPDATE which changes the WHERE-target column still finds the
|
|
/// post-update rows.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UpdateResult {
|
|
pub rows_affected: usize,
|
|
pub data: DataResult,
|
|
}
|
|
|
|
/// Outcome of a successful DELETE — the directly-deleted-row
|
|
/// count plus any cascade effects observed in child tables.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeleteResult {
|
|
pub rows_affected: usize,
|
|
pub cascade: Vec<CascadeEffect>,
|
|
}
|
|
|
|
/// One observed change in a child table caused by referential
|
|
/// action on a parent-side DELETE. Detected by row-count diffing
|
|
/// the child table immediately before and after the delete.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CascadeEffect {
|
|
pub relationship_name: String,
|
|
pub child_table: String,
|
|
pub rows_changed: i64,
|
|
pub action: ReferentialAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SqliteErrorKind {
|
|
/// `UNIQUE` constraint, including duplicate primary key.
|
|
UniqueViolation,
|
|
/// Referenced or operated-on table does not exist.
|
|
NoSuchTable,
|
|
/// Operated-on column does not exist.
|
|
NoSuchColumn,
|
|
/// Object (table, index, etc.) already exists.
|
|
AlreadyExists,
|
|
/// Catch-all.
|
|
Other,
|
|
}
|
|
|
|
impl DbError {
|
|
/// User-visible rendering of this error.
|
|
///
|
|
/// Routes through the H1 friendly-error layer
|
|
/// ([`crate::friendly::translate_error`], ADR-0019). With
|
|
/// no context the translator falls back to abstract wording
|
|
/// — for operation-tailored output, callers should
|
|
/// construct a [`crate::friendly::TranslateContext`] and
|
|
/// call the translator directly. This method exists for the
|
|
/// many callsites where context isn't readily available
|
|
/// (fatal-banner paths, fallback render paths) and the
|
|
/// abstract wording is acceptable.
|
|
#[must_use]
|
|
pub fn friendly_message(&self) -> String {
|
|
let ctx = crate::friendly::TranslateContext::default();
|
|
crate::friendly::translate_error(self, &ctx).render()
|
|
}
|
|
|
|
fn from_rusqlite(err: rusqlite::Error) -> Self {
|
|
let message = err.to_string();
|
|
let kind = classify_sqlite_error(&err, &message);
|
|
Self::Sqlite { message, kind }
|
|
}
|
|
|
|
fn from_persistence(err: PersistenceError) -> Self {
|
|
Self::PersistenceFatal {
|
|
operation: err.operation(),
|
|
path: err.path().to_path_buf(),
|
|
message: err.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Whether this error means the application cannot
|
|
/// continue and must quit (per ADR-0015 §8). The runtime
|
|
/// surfaces these as fatal banners.
|
|
#[must_use]
|
|
pub const fn is_fatal(&self) -> bool {
|
|
matches!(self, Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. })
|
|
}
|
|
}
|
|
|
|
fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
|
|
use rusqlite::ErrorCode;
|
|
if let rusqlite::Error::SqliteFailure(code, _) = err
|
|
&& code.code == ErrorCode::ConstraintViolation
|
|
{
|
|
return SqliteErrorKind::UniqueViolation;
|
|
}
|
|
let lowered = message.to_ascii_lowercase();
|
|
if lowered.contains("no such table") {
|
|
SqliteErrorKind::NoSuchTable
|
|
} else if lowered.contains("no such column") {
|
|
SqliteErrorKind::NoSuchColumn
|
|
} else if lowered.contains("already exists") {
|
|
SqliteErrorKind::AlreadyExists
|
|
} else {
|
|
SqliteErrorKind::Other
|
|
}
|
|
}
|
|
|
|
/// Internal request type — kept private so the channel protocol
|
|
/// is not part of the public API.
|
|
#[derive(Debug)]
|
|
enum Request {
|
|
CreateTable {
|
|
name: String,
|
|
columns: Vec<ColumnSpec>,
|
|
primary_key: Vec<String>,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
DropTable {
|
|
name: String,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<(), DbError>>,
|
|
},
|
|
AddColumn {
|
|
table: String,
|
|
column: ColumnSpec,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<AddColumnResult, DbError>>,
|
|
},
|
|
DropColumn {
|
|
table: String,
|
|
column: String,
|
|
cascade: bool,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<DropColumnResult, DbError>>,
|
|
},
|
|
RenameColumn {
|
|
table: String,
|
|
old: String,
|
|
new: String,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
ChangeColumnType {
|
|
table: String,
|
|
column: String,
|
|
ty: Type,
|
|
mode: ChangeColumnMode,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<ChangeColumnTypeResult, DbError>>,
|
|
},
|
|
ListTables {
|
|
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
|
},
|
|
DescribeTable {
|
|
name: String,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
AddRelationship {
|
|
name: Option<String>,
|
|
parent_table: String,
|
|
parent_column: String,
|
|
child_table: String,
|
|
child_column: String,
|
|
on_delete: ReferentialAction,
|
|
on_update: ReferentialAction,
|
|
create_fk: bool,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
DropRelationship {
|
|
selector: RelationshipSelector,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
|
|
},
|
|
/// Create an index on a table (ADR-0025).
|
|
AddIndex {
|
|
name: Option<String>,
|
|
table: String,
|
|
columns: Vec<String>,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
/// Drop an index by name or by table + column set (ADR-0025).
|
|
DropIndex {
|
|
selector: IndexSelector,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
/// Add a column-level constraint to an existing column
|
|
/// (ADR-0029 §2.2). The post-rebuild `TableDescription`
|
|
/// flows back through the standard auto-show path.
|
|
AddConstraint {
|
|
table: String,
|
|
column: String,
|
|
constraint: Constraint,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
/// Remove a column-level constraint from an existing column
|
|
/// (ADR-0029 §2.2).
|
|
DropConstraint {
|
|
table: String,
|
|
column: String,
|
|
kind: ConstraintKind,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
|
},
|
|
Insert {
|
|
table: String,
|
|
columns: Option<Vec<String>>,
|
|
values: Vec<Value>,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
|
},
|
|
Update {
|
|
table: String,
|
|
assignments: Vec<(String, Value)>,
|
|
filter: RowFilter,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
|
|
},
|
|
Delete {
|
|
table: String,
|
|
filter: RowFilter,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
|
|
},
|
|
QueryData {
|
|
table: String,
|
|
filter: Option<Expr>,
|
|
limit: Option<u64>,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
|
},
|
|
/// Run a SQL `SELECT` typed by the user in advanced mode
|
|
/// (ADR-0030 §6, ADR-0031). The grammar walker has already
|
|
/// validated `sql` is in the supported subset; the worker
|
|
/// prepares and runs the statement and returns the rows as
|
|
/// a [`DataResult`] (with no playground type information per
|
|
/// ADR-0030 §6 — computed columns render with neutral
|
|
/// alignment). `source` is the literal submitted line,
|
|
/// appended to `history.log` for replay (ADR-0030 §11).
|
|
RunSelect {
|
|
sql: String,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
|
},
|
|
/// Run a validated SQL `INSERT` typed in advanced mode
|
|
/// (ADR-0033 §1, sub-phase 3b). The grammar walker has
|
|
/// validated `sql` is in the supported subset; the worker
|
|
/// executes it as text, re-persists the target table's CSV
|
|
/// (ADR-0030 §11), and appends the literal line to
|
|
/// `history.log`. `target_table` comes from the parse so the
|
|
/// worker re-persists the right CSV without re-parsing.
|
|
RunSqlInsert {
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
listed_columns: Vec<String>,
|
|
row_source: String,
|
|
reply: oneshot::Sender<Result<InsertResult, DbError>>,
|
|
},
|
|
/// Run a grammar-validated SQL `UPDATE` (ADR-0033 §2). The
|
|
/// worker executes `sql` as text, re-persists `target_table`'s
|
|
/// CSV (ADR-0030 §11), and appends the literal line to
|
|
/// `history.log`.
|
|
RunSqlUpdate {
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
reply: oneshot::Sender<Result<UpdateResult, DbError>>,
|
|
},
|
|
/// Run a grammar-validated SQL `DELETE` (ADR-0033 §1/§7). The
|
|
/// worker executes `sql` as text, detects FK cascade by
|
|
/// row-count diffing the inbound children (Amendment 2),
|
|
/// re-persists `target_table`'s CSV plus every cascade-affected
|
|
/// child (ADR-0030 §11), and appends the literal line to
|
|
/// `history.log`.
|
|
RunSqlDelete {
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
reply: oneshot::Sender<Result<DeleteResult, DbError>>,
|
|
},
|
|
/// Capture the query plan for an explainable command via
|
|
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
|
|
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
|
|
/// never executes it, so this is read-only even for the
|
|
/// destructive variants.
|
|
ExplainPlan {
|
|
query: Command,
|
|
reply: oneshot::Sender<Result<QueryPlan, DbError>>,
|
|
},
|
|
/// Rebuild the database from `project.yaml` + `data/`
|
|
/// (ADR-0015 §7). Used by the runtime when the `.db` file
|
|
/// is missing on project open and by the explicit
|
|
/// `rebuild` app-level command (Iteration 4).
|
|
RebuildFromText {
|
|
project_path: std::path::PathBuf,
|
|
source: Option<String>,
|
|
reply: oneshot::Sender<Result<(), DbError>>,
|
|
},
|
|
/// Read both directions of FK relationships for `table`.
|
|
/// Returns `(outbound, inbound)` — outbound = rows where
|
|
/// `table` is the child (has FK columns pointing elsewhere);
|
|
/// inbound = rows where `table` is the parent (referenced
|
|
/// by other tables). Used by the friendly-error layer's
|
|
/// runtime enrichment (ADR-0019 §6).
|
|
ReadRelationships {
|
|
table: String,
|
|
reply: oneshot::Sender<RelationshipsReply>,
|
|
},
|
|
/// Find rows in `table` where `column` matches `value`.
|
|
/// Capped at `limit` rows. Used by the friendly-error
|
|
/// layer's row-pinpoint diagnostic (ADR-0019 §6, ADR-0017 §7).
|
|
/// Best-effort: returns empty rows on any failure (no row
|
|
/// matched, schema gone, type mismatch on bind, etc.).
|
|
FindRowsMatching {
|
|
table: String,
|
|
column: String,
|
|
value: Value,
|
|
limit: usize,
|
|
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
|
},
|
|
/// List schema entity names for a given identifier slot
|
|
/// (ADR-0022 §9). Used by the completion engine to offer
|
|
/// candidates for `TableName` / `Column` /
|
|
/// `RelationshipName` slots. `NewName` is rejected at
|
|
/// the caller — schema has nothing to offer for new
|
|
/// names — and never reaches the worker.
|
|
///
|
|
/// Returns names in stable (alphabetical) order, no
|
|
/// duplicates. The reply is small even for projects with
|
|
/// hundreds of tables/columns.
|
|
ListNamesFor {
|
|
source: crate::dsl::grammar::IdentSource,
|
|
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
|
},
|
|
}
|
|
|
|
impl Database {
|
|
/// Open a database without per-command persistence.
|
|
///
|
|
/// The path may be a filesystem location or `":memory:"`
|
|
/// for an ephemeral in-memory database. The connection is
|
|
/// moved onto a dedicated worker thread. With no
|
|
/// persistence handle, the YAML/CSV/`history.log` writes
|
|
/// are skipped — useful for unit tests that exercise the
|
|
/// SQLite layer in isolation.
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
|
|
Self::open_inner(path, None)
|
|
}
|
|
|
|
/// Open a database with per-command persistence wired in
|
|
/// (ADR-0015 §6). Every successful user-issued mutation
|
|
/// writes through to `project.yaml`, the affected
|
|
/// `data/<table>.csv` files, and `history.log` *before*
|
|
/// the SQLite tx commits.
|
|
pub fn open_with_persistence<P: AsRef<Path>>(
|
|
path: P,
|
|
persistence: Persistence,
|
|
) -> Result<Self, DbError> {
|
|
Self::open_inner(path, Some(persistence))
|
|
}
|
|
|
|
fn open_inner<P: AsRef<Path>>(
|
|
path: P,
|
|
persistence: Option<Persistence>,
|
|
) -> Result<Self, DbError> {
|
|
let path_display = path.as_ref().to_string_lossy().into_owned();
|
|
let conn = match path.as_ref().to_str() {
|
|
Some(":memory:") => Connection::open_in_memory(),
|
|
_ => Connection::open(path.as_ref()),
|
|
}
|
|
.map_err(DbError::from_rusqlite)?;
|
|
info!(path = %path_display, "opened database");
|
|
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
|
|
|
|
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
|
|
thread::Builder::new()
|
|
.name("rdbms-db-worker".to_string())
|
|
.spawn(move || worker_loop(conn, persistence, rx))
|
|
.map_err(|e| DbError::Io(e.to_string()))?;
|
|
|
|
Ok(Self { inbox: tx })
|
|
}
|
|
|
|
pub async fn create_table(
|
|
&self,
|
|
name: String,
|
|
columns: Vec<ColumnSpec>,
|
|
primary_key: Vec<String>,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::CreateTable {
|
|
name,
|
|
columns,
|
|
primary_key,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn drop_table(&self, name: String, source: Option<String>) -> Result<(), DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DropTable { name, source, reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn add_column(
|
|
&self,
|
|
table: String,
|
|
column: ColumnSpec,
|
|
source: Option<String>,
|
|
) -> Result<AddColumnResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::AddColumn {
|
|
table,
|
|
column,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn drop_column(
|
|
&self,
|
|
table: String,
|
|
column: String,
|
|
cascade: bool,
|
|
source: Option<String>,
|
|
) -> Result<DropColumnResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DropColumn {
|
|
table,
|
|
column,
|
|
cascade,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn add_index(
|
|
&self,
|
|
name: Option<String>,
|
|
table: String,
|
|
columns: Vec<String>,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::AddIndex {
|
|
name,
|
|
table,
|
|
columns,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn drop_index(
|
|
&self,
|
|
selector: IndexSelector,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DropIndex {
|
|
selector,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Add a column-level constraint to an existing column
|
|
/// (ADR-0029 §2.2).
|
|
pub async fn add_constraint(
|
|
&self,
|
|
table: String,
|
|
column: String,
|
|
constraint: Constraint,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::AddConstraint {
|
|
table,
|
|
column,
|
|
constraint,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Remove a column-level constraint from an existing column
|
|
/// (ADR-0029 §2.2).
|
|
pub async fn drop_constraint(
|
|
&self,
|
|
table: String,
|
|
column: String,
|
|
kind: ConstraintKind,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DropConstraint {
|
|
table,
|
|
column,
|
|
kind,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn rename_column(
|
|
&self,
|
|
table: String,
|
|
old: String,
|
|
new: String,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RenameColumn {
|
|
table,
|
|
old,
|
|
new,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn change_column_type(
|
|
&self,
|
|
table: String,
|
|
column: String,
|
|
ty: Type,
|
|
mode: ChangeColumnMode,
|
|
source: Option<String>,
|
|
) -> Result<ChangeColumnTypeResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::ChangeColumnType {
|
|
table,
|
|
column,
|
|
mode,
|
|
ty,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn list_tables(&self) -> Result<Vec<String>, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::ListTables { reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn describe_table(
|
|
&self,
|
|
name: String,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DescribeTable {
|
|
name,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn add_relationship(
|
|
&self,
|
|
name: Option<String>,
|
|
parent_table: String,
|
|
parent_column: String,
|
|
child_table: String,
|
|
child_column: String,
|
|
on_delete: ReferentialAction,
|
|
on_update: ReferentialAction,
|
|
create_fk: bool,
|
|
source: Option<String>,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::AddRelationship {
|
|
name,
|
|
parent_table,
|
|
parent_column,
|
|
child_table,
|
|
child_column,
|
|
on_delete,
|
|
on_update,
|
|
create_fk,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn drop_relationship(
|
|
&self,
|
|
selector: RelationshipSelector,
|
|
source: Option<String>,
|
|
) -> Result<Option<TableDescription>, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::DropRelationship {
|
|
selector,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn insert(
|
|
&self,
|
|
table: String,
|
|
columns: Option<Vec<String>>,
|
|
values: Vec<Value>,
|
|
source: Option<String>,
|
|
) -> Result<InsertResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::Insert {
|
|
table,
|
|
columns,
|
|
values,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn update(
|
|
&self,
|
|
table: String,
|
|
assignments: Vec<(String, Value)>,
|
|
filter: RowFilter,
|
|
source: Option<String>,
|
|
) -> Result<UpdateResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::Update {
|
|
table,
|
|
assignments,
|
|
filter,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn delete(
|
|
&self,
|
|
table: String,
|
|
filter: RowFilter,
|
|
source: Option<String>,
|
|
) -> Result<DeleteResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::Delete {
|
|
table,
|
|
filter,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Rebuild the database from `project.yaml` + `data/`
|
|
/// (ADR-0015 §7).
|
|
///
|
|
/// Called by the runtime on a missing `.db` at startup
|
|
/// (with `source = None`, no history entry) and by the
|
|
/// explicit `rebuild` app-level command (with
|
|
/// `source = Some("rebuild")`, which appends to
|
|
/// `history.log` on success).
|
|
pub async fn rebuild_from_text(
|
|
&self,
|
|
project_path: std::path::PathBuf,
|
|
source: Option<String>,
|
|
) -> Result<(), DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RebuildFromText {
|
|
project_path,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
pub async fn query_data(
|
|
&self,
|
|
table: String,
|
|
filter: Option<Expr>,
|
|
limit: Option<u64>,
|
|
source: Option<String>,
|
|
) -> Result<DataResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::QueryData {
|
|
table,
|
|
filter,
|
|
limit,
|
|
source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Run a validated SQL `SELECT` and return the rows
|
|
/// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated
|
|
/// statement text; `source` is the literal submitted line
|
|
/// for `history.log`.
|
|
pub async fn run_select(
|
|
&self,
|
|
sql: String,
|
|
source: Option<String>,
|
|
) -> Result<DataResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RunSelect { sql, source, reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Run a validated SQL `INSERT` and return the affected-row
|
|
/// count plus the inserted rows (ADR-0033 §1, sub-phase 3b).
|
|
/// `sql` is the grammar-validated statement text; `source` is
|
|
/// the literal submitted line for `history.log`; `target_table`
|
|
/// is the parsed target whose CSV is re-persisted.
|
|
pub async fn run_sql_insert(
|
|
&self,
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
listed_columns: Vec<String>,
|
|
row_source: String,
|
|
) -> Result<InsertResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RunSqlInsert {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
listed_columns,
|
|
row_source,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Run a validated SQL `UPDATE` and return the affected-row
|
|
/// count (ADR-0033 §2, sub-phase 3e). `sql` is the
|
|
/// grammar-validated statement text; `source` is the literal
|
|
/// submitted line for `history.log`; `target_table` is the
|
|
/// parsed target whose CSV is re-persisted.
|
|
pub async fn run_sql_update(
|
|
&self,
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
) -> Result<UpdateResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RunSqlUpdate {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Run a validated SQL `DELETE` and return the affected-row
|
|
/// count plus any cascade effects (ADR-0033 §1/§7, sub-phase
|
|
/// 3f). `sql` is the grammar-validated statement text; `source`
|
|
/// is the literal submitted line for `history.log`;
|
|
/// `target_table` is the parsed target whose CSV (and whose
|
|
/// cascade-affected children's CSVs) are re-persisted.
|
|
pub async fn run_sql_delete(
|
|
&self,
|
|
sql: String,
|
|
source: Option<String>,
|
|
target_table: String,
|
|
) -> Result<DeleteResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::RunSqlDelete {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Capture the query plan for an explainable command
|
|
/// (ADR-0028 §2). The wrapped command is not executed —
|
|
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
|
|
/// locate the rows — so this is safe even for `update` /
|
|
/// `delete`.
|
|
pub async fn explain_query_plan(
|
|
&self,
|
|
query: Command,
|
|
) -> Result<QueryPlan, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::ExplainPlan { query, reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Read both directions of FK relationships for `table`.
|
|
/// Used by the runtime's friendly-error enrichment to
|
|
/// resolve parent / child table names (ADR-0019 §6).
|
|
pub async fn read_relationships(
|
|
&self,
|
|
table: String,
|
|
) -> RelationshipsReply {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::ReadRelationships { table, reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// Pinpoint rows in `table` where `column` matches `value`.
|
|
/// Used by the runtime's friendly-error enrichment to
|
|
/// surface offending rows after a UNIQUE / FK violation
|
|
/// (ADR-0019 §6, ADR-0017 §7). Capped at `limit`.
|
|
pub async fn find_rows_matching(
|
|
&self,
|
|
table: String,
|
|
column: String,
|
|
value: Value,
|
|
limit: usize,
|
|
) -> Result<DataResult, DbError> {
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::FindRowsMatching {
|
|
table,
|
|
column,
|
|
value,
|
|
limit,
|
|
reply,
|
|
})
|
|
.await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
/// List schema entity names for an identifier source
|
|
/// (ADR-0022 §9, ADR-0024 §architecture).
|
|
///
|
|
/// Returns alphabetised, deduplicated names suitable for
|
|
/// the completion menu:
|
|
/// - `IdentSource::Tables` → user tables (filters
|
|
/// `__rdbms_*` internal tables);
|
|
/// - `IdentSource::Columns` → distinct column names
|
|
/// across all user tables (v1 simplification — no
|
|
/// table-context binding);
|
|
/// - `IdentSource::Relationships` → relationship names
|
|
/// from the metadata table;
|
|
/// - `IdentSource::NewName`, `Types`, `Free` → returns
|
|
/// `Ok(vec![])` immediately without a worker round-trip
|
|
/// (the user invents these names, or the source is
|
|
/// synthetic).
|
|
pub async fn list_names_for(
|
|
&self,
|
|
source: crate::dsl::grammar::IdentSource,
|
|
) -> Result<Vec<String>, DbError> {
|
|
if !source.completes_from_schema() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let (reply, recv) = oneshot::channel();
|
|
self.send(Request::ListNamesFor { source, reply }).await?;
|
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
|
}
|
|
|
|
async fn send(&self, req: Request) -> Result<(), DbError> {
|
|
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
|
}
|
|
}
|
|
|
|
/// Internal table tracking the user-facing type each column was
|
|
/// declared with. The structure view consults this so users see
|
|
/// the names they typed (`serial`, `date`) rather than SQLite's
|
|
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
|
|
/// promise. Track 2's project file format is the long-term home
|
|
/// for this metadata; this table is the in-database mirror that
|
|
/// makes round-trip rendering work today.
|
|
const META_TABLE: &str = "__rdbms_playground_columns";
|
|
const REL_TABLE: &str = "__rdbms_playground_relationships";
|
|
/// Single-row key/value table that carries project metadata
|
|
/// the YAML serializer needs to round-trip — currently
|
|
/// `created_at`. Created on first connect and only ever
|
|
/// written by us; the user never touches it directly.
|
|
const META_PROJECT_TABLE: &str = "__rdbms_playground_meta";
|
|
|
|
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|
conn.execute_batch(&format!(
|
|
"PRAGMA foreign_keys = ON;\n\
|
|
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
|
|
table_name TEXT NOT NULL,\n\
|
|
column_name TEXT NOT NULL,\n\
|
|
user_type TEXT NOT NULL,\n\
|
|
check_expr TEXT,\n\
|
|
PRIMARY KEY (table_name, column_name)\n\
|
|
) STRICT;\n\
|
|
CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\
|
|
name TEXT NOT NULL UNIQUE,\n\
|
|
parent_table TEXT NOT NULL,\n\
|
|
parent_column TEXT NOT NULL,\n\
|
|
child_table TEXT NOT NULL,\n\
|
|
child_column TEXT NOT NULL,\n\
|
|
on_delete TEXT NOT NULL,\n\
|
|
on_update TEXT NOT NULL,\n\
|
|
PRIMARY KEY (child_table, child_column)\n\
|
|
) STRICT;\n\
|
|
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
|
|
key TEXT NOT NULL PRIMARY KEY,\n\
|
|
value TEXT NOT NULL\n\
|
|
) STRICT;"
|
|
))?;
|
|
// Seed `created_at` once. INSERT OR IGNORE keeps the value
|
|
// stable across re-opens of the same database.
|
|
conn.execute(
|
|
&format!(
|
|
"INSERT OR IGNORE INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1)"
|
|
),
|
|
[iso8601_now()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Current UTC time as ISO-8601 with second precision.
|
|
/// Duplicates the helper in `persistence::history` rather
|
|
/// than depending on it, since this code path runs at
|
|
/// connection setup before the rest of the worker is up.
|
|
fn iso8601_now() -> String {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let day_secs = secs.rem_euclid(86_400);
|
|
let h = day_secs / 3600;
|
|
let m = (day_secs % 3600) / 60;
|
|
let s = day_secs % 60;
|
|
let days = secs.div_euclid(86_400);
|
|
let z = days + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = (z - era * 146_097) as u64;
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe as i64 + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
let m_cal = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let y_cal = if m_cal <= 2 { y + 1 } else { y };
|
|
format!("{y_cal:04}-{m_cal:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
|
}
|
|
|
|
fn worker_loop(
|
|
conn: Connection,
|
|
persistence: Option<Persistence>,
|
|
mut rx: mpsc::Receiver<Request>,
|
|
) {
|
|
debug!("db worker started");
|
|
while let Some(req) = rx.blocking_recv() {
|
|
handle_request(&conn, persistence.as_ref(), req);
|
|
}
|
|
debug!("db worker exiting");
|
|
}
|
|
|
|
fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Request) {
|
|
match req {
|
|
Request::CreateTable {
|
|
name,
|
|
columns,
|
|
primary_key,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_create_table(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&name,
|
|
&columns,
|
|
&primary_key,
|
|
));
|
|
}
|
|
Request::DropTable {
|
|
name,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_drop_table(conn, persistence, source.as_deref(), &name));
|
|
}
|
|
Request::AddColumn {
|
|
table,
|
|
column,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_add_column(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&column,
|
|
));
|
|
}
|
|
Request::DropColumn {
|
|
table,
|
|
column,
|
|
cascade,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_drop_column(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&column,
|
|
cascade,
|
|
));
|
|
}
|
|
Request::RenameColumn {
|
|
table,
|
|
old,
|
|
new,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_rename_column(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&old,
|
|
&new,
|
|
));
|
|
}
|
|
Request::ChangeColumnType {
|
|
table,
|
|
column,
|
|
ty,
|
|
mode,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_change_column_type(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&column,
|
|
ty,
|
|
mode,
|
|
));
|
|
}
|
|
Request::ListTables { reply } => {
|
|
let _ = reply.send(do_list_tables(conn));
|
|
}
|
|
Request::DescribeTable {
|
|
name,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_describe_table_request(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&name,
|
|
));
|
|
}
|
|
Request::AddRelationship {
|
|
name,
|
|
parent_table,
|
|
parent_column,
|
|
child_table,
|
|
child_column,
|
|
on_delete,
|
|
on_update,
|
|
create_fk,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_add_relationship(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
name.as_deref(),
|
|
&parent_table,
|
|
&parent_column,
|
|
&child_table,
|
|
&child_column,
|
|
on_delete,
|
|
on_update,
|
|
create_fk,
|
|
));
|
|
}
|
|
Request::DropRelationship {
|
|
selector,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_drop_relationship(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&selector,
|
|
));
|
|
}
|
|
Request::AddIndex {
|
|
name,
|
|
table,
|
|
columns,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_add_index(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
name.as_deref(),
|
|
&table,
|
|
&columns,
|
|
));
|
|
}
|
|
Request::DropIndex {
|
|
selector,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_drop_index(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&selector,
|
|
));
|
|
}
|
|
Request::AddConstraint {
|
|
table,
|
|
column,
|
|
constraint,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_add_constraint(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&column,
|
|
&constraint,
|
|
));
|
|
}
|
|
Request::DropConstraint {
|
|
table,
|
|
column,
|
|
kind,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_drop_constraint(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&column,
|
|
kind,
|
|
));
|
|
}
|
|
Request::Insert {
|
|
table,
|
|
columns,
|
|
values,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_insert(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
columns.as_deref(),
|
|
&values,
|
|
));
|
|
}
|
|
Request::Update {
|
|
table,
|
|
assignments,
|
|
filter,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_update(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&assignments,
|
|
&filter,
|
|
));
|
|
}
|
|
Request::Delete {
|
|
table,
|
|
filter,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_delete(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
&filter,
|
|
));
|
|
}
|
|
Request::QueryData {
|
|
table,
|
|
filter,
|
|
limit,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_query_data_request(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&table,
|
|
filter.as_ref(),
|
|
limit,
|
|
));
|
|
}
|
|
Request::RunSelect { sql, source, reply } => {
|
|
let _ = reply.send(do_run_select_request(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&sql,
|
|
));
|
|
}
|
|
Request::RunSqlInsert {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
listed_columns,
|
|
row_source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_sql_insert(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&sql,
|
|
&target_table,
|
|
&listed_columns,
|
|
&row_source,
|
|
));
|
|
}
|
|
Request::RunSqlUpdate {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_sql_update(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&sql,
|
|
&target_table,
|
|
));
|
|
}
|
|
Request::RunSqlDelete {
|
|
sql,
|
|
source,
|
|
target_table,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_sql_delete(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&sql,
|
|
&target_table,
|
|
));
|
|
}
|
|
Request::RebuildFromText {
|
|
project_path,
|
|
source,
|
|
reply,
|
|
} => {
|
|
let _ = reply.send(do_rebuild_from_text(
|
|
conn,
|
|
persistence,
|
|
source.as_deref(),
|
|
&project_path,
|
|
));
|
|
}
|
|
Request::ExplainPlan { query, reply } => {
|
|
let _ = reply.send(do_explain_plan(conn, &query));
|
|
}
|
|
Request::ReadRelationships { table, reply } => {
|
|
let result = do_read_relationships(conn, &table);
|
|
let _ = reply.send(result);
|
|
}
|
|
Request::FindRowsMatching {
|
|
table,
|
|
column,
|
|
value,
|
|
limit,
|
|
reply,
|
|
} => {
|
|
let result = do_find_rows_matching(conn, &table, &column, &value, limit);
|
|
let _ = reply.send(result);
|
|
}
|
|
Request::ListNamesFor { source, reply } => {
|
|
let result = do_list_names_for(conn, source);
|
|
let _ = reply.send(result);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Schema-name lookup for the completion engine
|
|
/// (ADR-0022 §9). Non-schema sources (`NewName`, `Types`, `Free`)
|
|
/// never reach here — the public `list_names_for` short-circuits.
|
|
fn do_list_names_for(
|
|
conn: &Connection,
|
|
source: crate::dsl::grammar::IdentSource,
|
|
) -> Result<Vec<String>, DbError> {
|
|
use crate::dsl::grammar::IdentSource;
|
|
match source {
|
|
IdentSource::Tables => do_list_tables(conn),
|
|
IdentSource::Columns => {
|
|
// Distinct column names across all user tables.
|
|
// v1 simplification: no table-context binding
|
|
// (ADR-0022 stage 6 note).
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT DISTINCT column_name \
|
|
FROM {META_TABLE} \
|
|
ORDER BY column_name;"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
IdentSource::Relationships => {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT name FROM {REL_TABLE} ORDER BY name;"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
IdentSource::Indexes => {
|
|
// User indexes only: a `CREATE INDEX` statement
|
|
// leaves a non-null `sql`, whereas the automatic
|
|
// indexes backing PKs / UNIQUE constraints have a
|
|
// null `sql`.
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master \
|
|
WHERE type = 'index' AND sql IS NOT NULL \
|
|
ORDER BY name;",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
IdentSource::NewName | IdentSource::Types | IdentSource::Free => Ok(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Read both directions of FK relationships for `table`. Used
|
|
/// by `Request::ReadRelationships` (ADR-0019 §6 enrichment).
|
|
fn do_read_relationships(conn: &Connection, table: &str) -> RelationshipsReply {
|
|
let outbound = read_relationships_outbound(conn, table)?;
|
|
let inbound = read_relationships_inbound(conn, table)?;
|
|
Ok((outbound, inbound))
|
|
}
|
|
|
|
/// `SELECT * FROM <table> WHERE <column> = <value> LIMIT <n>`.
|
|
/// Used by the runtime to pinpoint rows after a UNIQUE / FK
|
|
/// violation (ADR-0019 §6, ADR-0017 §7). Returns
|
|
/// `DbError::Sqlite` on bind failure, missing column, etc. —
|
|
/// callers treat any error as "no diagnostic table available"
|
|
/// and fall back to the headline-only wording.
|
|
fn do_find_rows_matching(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
value: &Value,
|
|
limit: usize,
|
|
) -> Result<DataResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
let col_info = schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {table}.{column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
let ty = col_info.user_type.ok_or_else(|| {
|
|
DbError::Unsupported(format!(
|
|
"column `{column}` has no user-type metadata; cannot pinpoint"
|
|
))
|
|
})?;
|
|
let bound = value
|
|
.bind_for_column(column, ty)
|
|
.map_err(|e| DbError::InvalidValue(e.to_string()))?;
|
|
|
|
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
let column_types: Vec<Option<Type>> =
|
|
schema.columns.iter().map(|c| c.user_type).collect();
|
|
let cols_csv = column_names
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sql = format!(
|
|
"SELECT {cols} FROM {tbl} WHERE {col} = ?1 LIMIT {n};",
|
|
cols = cols_csv,
|
|
tbl = quote_ident(table),
|
|
col = quote_ident(column),
|
|
n = limit,
|
|
);
|
|
|
|
let bound_value = bound_to_sqlite_value(&bound);
|
|
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
let rows_iter = stmt
|
|
.query_map(rusqlite::params![bound_value], |row| {
|
|
let mut cells: Vec<rusqlite::types::Value> =
|
|
Vec::with_capacity(column_names.len());
|
|
for i in 0..column_names.len() {
|
|
cells.push(row.get(i)?);
|
|
}
|
|
Ok(cells)
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
|
for r in rows_iter {
|
|
let cells = r.map_err(DbError::from_rusqlite)?;
|
|
let formatted: Vec<Option<String>> = cells
|
|
.into_iter()
|
|
.zip(column_types.iter())
|
|
.map(|(v, ty)| format_cell(v, *ty))
|
|
.collect();
|
|
rows.push(formatted);
|
|
}
|
|
Ok(DataResult {
|
|
table_name: table.to_string(),
|
|
columns: column_names,
|
|
column_types,
|
|
rows,
|
|
})
|
|
}
|
|
|
|
/// Set of changes a mutation made, used by the post-mutation
|
|
/// persistence phase to know which text targets need refreshing
|
|
/// (ADR-0015 §6).
|
|
#[derive(Debug, Default)]
|
|
struct Changes {
|
|
/// Schema (tables / columns / relationships) was modified —
|
|
/// `project.yaml` needs to be rewritten.
|
|
schema_dirty: bool,
|
|
/// Tables whose row data changed and whose CSVs need to be
|
|
/// re-emitted from the post-mutation state.
|
|
rewritten_tables: Vec<String>,
|
|
/// Tables that were dropped — their CSVs should be removed.
|
|
deleted_tables: Vec<String>,
|
|
}
|
|
|
|
/// Drive the post-mutation persistence phase: write the YAML
|
|
/// schema, rewrite affected CSVs, append `history.log`. Called
|
|
/// before `tx.commit()` so a failure here causes the SQLite
|
|
/// transaction to roll back automatically (the `Drop` impl on
|
|
/// `Transaction` rolls back on drop).
|
|
///
|
|
/// Read-only requests (no schema change, no row writes, no
|
|
/// drops) still use this to append `history.log` if `source`
|
|
/// is set; they pass an empty `Changes`.
|
|
fn finalize_persistence(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
changes: &Changes,
|
|
) -> Result<(), DbError> {
|
|
let Some(p) = persistence else {
|
|
return Ok(());
|
|
};
|
|
if changes.schema_dirty {
|
|
let schema = read_schema_snapshot(conn)?;
|
|
p.write_schema(&schema).map_err(DbError::from_persistence)?;
|
|
}
|
|
for table in &changes.rewritten_tables {
|
|
if let Some(snapshot) = read_table_snapshot(conn, table)? {
|
|
p.write_table_data(&snapshot)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
}
|
|
for table in &changes.deleted_tables {
|
|
p.delete_table_data(table)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
if let Some(text) = source {
|
|
p.append_history(text)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the full user-facing schema (tables, columns,
|
|
/// relationships, project metadata) from the database. Reads
|
|
/// committed *or* in-tx state because SQLite presents the
|
|
/// same connection's writes back through the same connection.
|
|
fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
|
|
let table_names = do_list_tables(conn)?;
|
|
let mut tables: Vec<TableSchema> = Vec::with_capacity(table_names.len());
|
|
for name in &table_names {
|
|
let read = read_schema(conn, name)?;
|
|
let columns: Vec<ColumnSchema> = read
|
|
.columns
|
|
.iter()
|
|
.map(|c| ColumnSchema {
|
|
name: c.name.clone(),
|
|
unique: c.unique,
|
|
not_null: c.notnull,
|
|
default: c.default_sql.clone(),
|
|
check: c.check.clone(),
|
|
// user_type is always populated for tables we
|
|
// created; the fallback is defensive.
|
|
user_type: c.user_type.unwrap_or(Type::Text),
|
|
})
|
|
.collect();
|
|
tables.push(TableSchema {
|
|
name: name.clone(),
|
|
primary_key: read.primary_key.clone(),
|
|
columns,
|
|
});
|
|
}
|
|
|
|
let relationships = read_all_relationships(conn)?;
|
|
let mut indexes: Vec<IndexSchema> = Vec::new();
|
|
for name in &table_names {
|
|
for idx in read_table_indexes(conn, name)? {
|
|
indexes.push(IndexSchema {
|
|
name: idx.name,
|
|
table: name.clone(),
|
|
columns: idx.columns,
|
|
});
|
|
}
|
|
}
|
|
let created_at = read_project_created_at(conn)?;
|
|
Ok(SchemaSnapshot {
|
|
created_at,
|
|
tables,
|
|
relationships,
|
|
indexes,
|
|
})
|
|
}
|
|
|
|
fn read_all_relationships(conn: &Connection) -> Result<Vec<RelationshipSchema>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT name, parent_table, parent_column, child_table, child_column, \
|
|
on_delete, on_update \
|
|
FROM {REL_TABLE} \
|
|
ORDER BY rowid"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(RelationshipSchema {
|
|
name: row.get(0)?,
|
|
parent_table: row.get(1)?,
|
|
parent_column: row.get(2)?,
|
|
child_table: row.get(3)?,
|
|
child_column: row.get(4)?,
|
|
on_delete: parse_action_from_sqlite(row.get::<_, String>(5)?.as_str()),
|
|
on_update: parse_action_from_sqlite(row.get::<_, String>(6)?.as_str()),
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn read_project_created_at(conn: &Connection) -> Result<String, DbError> {
|
|
let value: Option<String> = conn
|
|
.query_row(
|
|
&format!("SELECT value FROM {META_PROJECT_TABLE} WHERE key = 'created_at'"),
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.or_else(|e| match e {
|
|
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
|
other => Err(other),
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
Ok(value.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()))
|
|
}
|
|
|
|
/// Read a single table's full row data, returning `None` if
|
|
/// the table no longer exists (e.g. a recent `drop_table`).
|
|
fn read_table_snapshot(
|
|
conn: &Connection,
|
|
table: &str,
|
|
) -> Result<Option<TableSnapshot>, DbError> {
|
|
if !user_table_exists(conn, table)? {
|
|
return Ok(None);
|
|
}
|
|
let read = read_schema(conn, table)?;
|
|
let columns: Vec<ColumnSchema> = read
|
|
.columns
|
|
.iter()
|
|
.map(|c| ColumnSchema {
|
|
name: c.name.clone(),
|
|
user_type: c.user_type.unwrap_or(Type::Text),
|
|
unique: c.unique,
|
|
not_null: c.notnull,
|
|
default: c.default_sql.clone(),
|
|
check: c.check.clone(),
|
|
})
|
|
.collect();
|
|
let column_idents: Vec<String> = read
|
|
.columns
|
|
.iter()
|
|
.map(|c| quote_ident(&c.name))
|
|
.collect();
|
|
let sql = format!(
|
|
"SELECT {} FROM {} ORDER BY rowid",
|
|
column_idents.join(", "),
|
|
quote_ident(table),
|
|
);
|
|
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
let column_count = read.columns.len();
|
|
let mut rows: Vec<Vec<CellValue>> = Vec::new();
|
|
let mut iter = stmt.query([]).map_err(DbError::from_rusqlite)?;
|
|
while let Some(row) = iter.next().map_err(DbError::from_rusqlite)? {
|
|
let mut record: Vec<CellValue> = Vec::with_capacity(column_count);
|
|
for i in 0..column_count {
|
|
record.push(row_value_to_cell(row, i)?);
|
|
}
|
|
rows.push(record);
|
|
}
|
|
Ok(Some(TableSnapshot {
|
|
name: table.to_string(),
|
|
columns,
|
|
rows,
|
|
}))
|
|
}
|
|
|
|
fn user_table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_schema \
|
|
WHERE type = 'table' AND name = ?1 \
|
|
AND substr(name, 1, 8) != '__rdbms_'",
|
|
[table],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
Ok(count > 0)
|
|
}
|
|
|
|
fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, DbError> {
|
|
use rusqlite::types::ValueRef;
|
|
let v = row.get_ref(idx).map_err(DbError::from_rusqlite)?;
|
|
Ok(match v {
|
|
ValueRef::Null => CellValue::Null,
|
|
ValueRef::Integer(n) => CellValue::Integer(n),
|
|
ValueRef::Real(f) => CellValue::Real(f),
|
|
ValueRef::Text(bytes) => {
|
|
CellValue::Text(String::from_utf8_lossy(bytes).into_owned())
|
|
}
|
|
ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()),
|
|
})
|
|
}
|
|
|
|
/// Quote an identifier for safe inclusion in DDL. Doubles any
|
|
/// embedded double-quotes per SQL convention.
|
|
fn quote_ident(name: &str) -> String {
|
|
let mut out = String::with_capacity(name.len() + 2);
|
|
out.push('"');
|
|
for c in name.chars() {
|
|
if c == '"' {
|
|
out.push_str("\"\"");
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
/// Render a column's constraint-DDL suffix (ADR-0029) — the
|
|
/// ` NOT NULL` / ` UNIQUE` / ` DEFAULT <literal>` fragment that
|
|
/// follows the column's type in a `CREATE TABLE` or `ALTER
|
|
/// TABLE ADD COLUMN`. The default literal is bound against the
|
|
/// column's user-facing type, so `default 18` on an `int`
|
|
/// column emits `DEFAULT 18` and `default 'x'` on a `text`
|
|
/// column emits `DEFAULT 'x'`. (`CHECK` joins this in a later
|
|
/// ADR-0029 step.)
|
|
fn column_constraints_sql(spec: &ColumnSpec) -> Result<String, DbError> {
|
|
let mut sql = String::new();
|
|
if spec.not_null {
|
|
sql.push_str(" NOT NULL");
|
|
}
|
|
if spec.unique {
|
|
sql.push_str(" UNIQUE");
|
|
}
|
|
if let Some(literal) = default_sql_literal(spec)? {
|
|
sql.push_str(" DEFAULT ");
|
|
sql.push_str(&literal);
|
|
}
|
|
Ok(sql)
|
|
}
|
|
|
|
/// The SQL literal for a column's `DEFAULT` value, bound
|
|
/// against the column's user-facing type (ADR-0029). `None`
|
|
/// when the column carries no default.
|
|
fn default_sql_literal(spec: &ColumnSpec) -> Result<Option<String>, DbError> {
|
|
match &spec.default {
|
|
Some(value) => Ok(Some(value_to_default_sql(value, &spec.name, spec.ty)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// The SQL literal for a `DEFAULT` `value` on a `column` of
|
|
/// user-facing type `ty` (ADR-0029) — the value-bound,
|
|
/// type-checked rendering shared by `default_sql_literal` (the
|
|
/// create-table / add-column path) and `do_add_constraint`
|
|
/// (the `add constraint default` path).
|
|
fn value_to_default_sql(value: &Value, column: &str, ty: Type) -> Result<String, DbError> {
|
|
let bound = value
|
|
.bind_for_column(column, ty)
|
|
.map_err(|e| DbError::InvalidValue(e.to_string()))?;
|
|
Ok(sql_literal(&bound_to_sqlite_value(&bound)))
|
|
}
|
|
|
|
/// Compile a `CHECK` expression to inline SQL (ADR-0029 §4 /
|
|
/// §7) — the form stored in the `check_expr` metadata column
|
|
/// and emitted into column DDL. `compile_expr` produces
|
|
/// `?N`-parameterised SQL; `inline_params_for_display`
|
|
/// (ADR-0028) folds the literals back in, since DDL admits no
|
|
/// parameters.
|
|
fn compile_check_sql(expr: &Expr, schema: &ReadSchema) -> String {
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
|
let sql = compile_expr(expr, schema, &mut params);
|
|
inline_params_for_display(&sql, ¶ms)
|
|
}
|
|
|
|
/// A minimal `ReadSchema` built from column specs — enough for
|
|
/// `compile_expr` to resolve column types when compiling a
|
|
/// `CHECK` at create-table time, before the table exists.
|
|
fn read_schema_for_specs(columns: &[ColumnSpec], primary_key: &[String]) -> ReadSchema {
|
|
ReadSchema {
|
|
columns: columns
|
|
.iter()
|
|
.map(|c| ReadColumn {
|
|
name: c.name.clone(),
|
|
sqlite_type: c.ty.sqlite_strict_type().to_string(),
|
|
notnull: c.not_null,
|
|
primary_key: primary_key.contains(&c.name),
|
|
unique: c.unique,
|
|
default_sql: None,
|
|
check: None,
|
|
user_type: Some(c.ty),
|
|
})
|
|
.collect(),
|
|
primary_key: primary_key.to_vec(),
|
|
foreign_keys: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Insert a column's row into the metadata table — the user
|
|
/// type, plus the compiled `CHECK` SQL when present
|
|
/// (ADR-0029 §7).
|
|
fn insert_column_metadata(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
user_type: Type,
|
|
check_sql: Option<&str>,
|
|
) -> Result<(), DbError> {
|
|
conn.execute(
|
|
&format!(
|
|
"INSERT INTO {META_TABLE} \
|
|
(table_name, column_name, user_type, check_expr) \
|
|
VALUES (?1, ?2, ?3, ?4);"
|
|
),
|
|
rusqlite::params![table, column, user_type.keyword(), check_sql],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn do_create_table(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
name: &str,
|
|
columns: &[ColumnSpec],
|
|
primary_key: &[String],
|
|
) -> Result<TableDescription, DbError> {
|
|
if columns.is_empty() {
|
|
// SQLite requires at least one column. The DSL grammar
|
|
// already prevents this, but defending here too keeps
|
|
// the executor honest if anyone synthesises a Command
|
|
// directly (tests, future scripting).
|
|
return Err(DbError::Unsupported(
|
|
"tables need at least one column".to_string(),
|
|
));
|
|
}
|
|
|
|
// Generate the column list. For a single-column PK we inline
|
|
// `PRIMARY KEY` on the column itself, which is required for
|
|
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
|
|
// column its rowid-alias semantics. For compound PKs (or
|
|
// when the single PK is on a non-first column) we emit a
|
|
// table-level constraint.
|
|
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
|
|
&& primary_key[0] == columns[0].name;
|
|
|
|
// Compile each column's CHECK once (ADR-0029 §4) — reused
|
|
// by the DDL clause and the metadata insert below. The
|
|
// minimal schema gives `compile_expr` the column types.
|
|
let check_schema = read_schema_for_specs(columns, primary_key);
|
|
let check_sqls: Vec<Option<String>> = columns
|
|
.iter()
|
|
.map(|c| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema)))
|
|
.collect();
|
|
|
|
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
|
|
for (col, check_sql) in columns.iter().zip(&check_sqls) {
|
|
let mut clause = format!(
|
|
"{ident} {sqlite_type}",
|
|
ident = quote_ident(&col.name),
|
|
sqlite_type = col.ty.sqlite_strict_type(),
|
|
);
|
|
if single_inline_pk {
|
|
clause.push_str(" PRIMARY KEY");
|
|
}
|
|
// ADR-0029 column constraints. A single-column PK is
|
|
// already NOT NULL + UNIQUE; the grammar rejects
|
|
// redundant declarations (ADR-0029 §9) so a PK column
|
|
// never carries them here.
|
|
clause.push_str(&column_constraints_sql(col)?);
|
|
if let Some(cs) = check_sql {
|
|
clause.push_str(&format!(" CHECK ({cs})"));
|
|
}
|
|
column_clauses.push(clause);
|
|
}
|
|
|
|
let mut ddl = format!(
|
|
"CREATE TABLE {ident} ({columns}",
|
|
ident = quote_ident(name),
|
|
columns = column_clauses.join(", "),
|
|
);
|
|
|
|
if !single_inline_pk && !primary_key.is_empty() {
|
|
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
|
|
ddl.push_str(", PRIMARY KEY (");
|
|
ddl.push_str(&pk_idents.join(", "));
|
|
ddl.push(')');
|
|
}
|
|
|
|
ddl.push_str(") STRICT;");
|
|
debug!(ddl = %ddl, "create_table");
|
|
|
|
// Wrap the table-creation DDL and the metadata inserts in a
|
|
// single transaction so they commit atomically — if either
|
|
// step fails, neither side persists.
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
for (col, check_sql) in columns.iter().zip(&check_sqls) {
|
|
insert_column_metadata(&tx, name, &col.name, col.ty, check_sql.as_deref())?;
|
|
}
|
|
let description = do_describe_table(conn, name)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![name.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(description)
|
|
}
|
|
|
|
fn do_drop_table(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
name: &str,
|
|
) -> Result<(), DbError> {
|
|
// Refuse the drop while any *other* table still has a
|
|
// relationship pointing at this one — dropping the parent
|
|
// would leave dangling FK constraints in the children. The
|
|
// user is told which relationships to drop first.
|
|
let inbound = read_relationships_inbound(conn, name)?;
|
|
if !inbound.is_empty() {
|
|
let names: Vec<&str> = inbound.iter().map(|r| r.name.as_str()).collect();
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot drop `{name}`: it is referenced by relationship(s) [{}]. \
|
|
Drop those relationships first.",
|
|
names.join(", ")
|
|
)));
|
|
}
|
|
|
|
let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
|
|
debug!(ddl = %ddl, "drop_table");
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
tx.execute(
|
|
&format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
|
|
[name],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
// Outbound relationships are gone with the table — clean
|
|
// their metadata too.
|
|
tx.execute(
|
|
&format!("DELETE FROM {REL_TABLE} WHERE child_table = ?1;"),
|
|
[name],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: Vec::new(),
|
|
deleted_tables: vec![name.to_string()],
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Add a column to an existing table.
|
|
///
|
|
/// Two execution paths per ADR-0018 §6:
|
|
///
|
|
/// - **Plain types** (`text`, `int`, `real`, `decimal`, `bool`,
|
|
/// `date`, `datetime`): `ALTER TABLE ADD COLUMN`. Existing
|
|
/// rows get NULL in the new column — that's the SQL standard
|
|
/// behaviour and acceptable for non-auto-generated types.
|
|
/// - **Auto-generated types** (`serial`, `shortid`): route
|
|
/// through the rebuild-table primitive so we can populate
|
|
/// the new column atomically with table creation. Existing
|
|
/// rows receive a generated value (1..N for serial, fresh
|
|
/// shortids for shortid) and the column gains UNIQUE +
|
|
/// NOT NULL.
|
|
///
|
|
/// `blob` is statically refused as a column-add target by the
|
|
/// existing infrastructure (no DSL literal, downstream INSERT
|
|
/// would fail), but the path itself is allowed via the plain
|
|
/// branch — same as today.
|
|
fn do_add_column(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
column: &ColumnSpec,
|
|
) -> Result<AddColumnResult, DbError> {
|
|
if matches!(column.ty, Type::Serial | Type::ShortId) {
|
|
// ADR-0029 §6: a `serial` / `shortid` column auto-fills
|
|
// its own values, so a separate `default` is ambiguous.
|
|
if column.default.is_some() {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{name}` is a {ty} column — it auto-fills its own values, \
|
|
so it cannot also carry a `default`.",
|
|
name = column.name,
|
|
ty = column.ty.keyword(),
|
|
)));
|
|
}
|
|
// A CHECK on an auto-generated column is supported at
|
|
// `create table` time; adding one to a `serial` /
|
|
// `shortid` column afterwards is not (the auto-fill
|
|
// rebuild path does not thread it).
|
|
if column.check.is_some() {
|
|
return Err(DbError::Unsupported(format!(
|
|
"a `check` constraint on the auto-generated column `{}` \
|
|
can only be set when the table is created.",
|
|
column.name,
|
|
)));
|
|
}
|
|
return do_add_auto_generated_column(conn, persistence, source, table, column);
|
|
}
|
|
// SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE`
|
|
// or `CHECK`, and a `NOT NULL` column added that way must
|
|
// carry a default — all route through the rebuild
|
|
// primitive instead (ADR-0029 §6).
|
|
if column.unique || column.check.is_some() || (column.not_null && column.default.is_none())
|
|
{
|
|
do_add_constrained_column_via_rebuild(conn, persistence, source, table, column)
|
|
} else {
|
|
do_add_plain_column(conn, persistence, source, table, column)
|
|
}
|
|
}
|
|
|
|
/// Plain ALTER-TABLE path for non-auto-generated types.
|
|
fn do_add_plain_column(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
spec: &ColumnSpec,
|
|
) -> Result<AddColumnResult, DbError> {
|
|
// The plain `ALTER TABLE ADD COLUMN` path. `do_add_column`
|
|
// only routes here when the constraints are ALTER-expressible
|
|
// (no UNIQUE; NOT NULL only alongside a default), so the
|
|
// ADR-0029 suffix appends cleanly.
|
|
let ty = spec.ty;
|
|
let column = spec.name.as_str();
|
|
let ddl = format!(
|
|
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{constraints};",
|
|
tbl = quote_ident(table),
|
|
col = quote_ident(column),
|
|
sqlite_type = ty.sqlite_strict_type(),
|
|
constraints = column_constraints_sql(spec)?,
|
|
);
|
|
debug!(ddl = %ddl, "add_column");
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
tx.execute(
|
|
&format!(
|
|
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
|
VALUES (?1, ?2, ?3);"
|
|
),
|
|
[table, column, ty.keyword()],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let description = do_describe_table(conn, table)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(AddColumnResult {
|
|
description,
|
|
client_side_notes: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Auto-fill path for `serial` / `shortid` (ADR-0018 §6 + §9).
|
|
///
|
|
/// Empty table: the new column is added through the rebuild
|
|
/// primitive too, but no auto-fill / `[client-side]` note is
|
|
/// produced — there are no rows to populate.
|
|
fn do_add_auto_generated_column(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
spec: &ColumnSpec,
|
|
) -> Result<AddColumnResult, DbError> {
|
|
use rusqlite::types::Value as RV;
|
|
|
|
let ty = spec.ty;
|
|
let column = spec.name.as_str();
|
|
let old_schema = read_schema(conn, table)?;
|
|
if old_schema.columns.iter().any(|c| c.name == column) {
|
|
return Err(DbError::Unsupported(format!(
|
|
"column `{table}.{column}` already exists."
|
|
)));
|
|
}
|
|
let row_count = count_rows(conn, table)? as usize;
|
|
|
|
// Build the new schema with the auto-generated column
|
|
// appended. UNIQUE + NOT NULL emit per ADR-0018 §4.
|
|
let mut new_schema = old_schema.clone();
|
|
new_schema.columns.push(ReadColumn {
|
|
name: column.to_string(),
|
|
sqlite_type: ty.sqlite_strict_type().to_string(),
|
|
notnull: true,
|
|
primary_key: false,
|
|
unique: true,
|
|
default_sql: None,
|
|
check: None,
|
|
user_type: Some(ty),
|
|
});
|
|
|
|
// Generate auto-fill values for every existing row.
|
|
let auto_fill_values: Vec<RV> = match ty {
|
|
Type::Serial => (1..=row_count as i64).map(RV::Integer).collect(),
|
|
Type::ShortId => generate_shortid_batch(row_count, &[])?,
|
|
_ => unreachable!("guarded by `auto_generated` flag"),
|
|
};
|
|
|
|
let old_columns: Vec<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
let new_columns: Vec<String> = new_schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
|
|
let copy_data = |tx: &rusqlite::Transaction<'_>,
|
|
temp_name: &str,
|
|
orig: &str|
|
|
-> Result<(), DbError> {
|
|
if row_count == 0 {
|
|
return Ok(());
|
|
}
|
|
// Read all rows from old, append the auto-fill value,
|
|
// INSERT into temp.
|
|
let select_cols = old_columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let select_sql = format!(
|
|
"SELECT {select_cols} FROM {orig};",
|
|
orig = quote_ident(orig),
|
|
);
|
|
let cols_csv = new_columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let placeholders = (1..=new_columns.len())
|
|
.map(|i| format!("?{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let insert_sql = format!(
|
|
"INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});",
|
|
temp = quote_ident(temp_name),
|
|
);
|
|
|
|
let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?;
|
|
let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?;
|
|
let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?;
|
|
let mut row_idx = 0usize;
|
|
while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
|
|
let mut values: Vec<RV> = Vec::with_capacity(new_columns.len());
|
|
for i in 0..old_columns.len() {
|
|
values.push(r.get(i).map_err(DbError::from_rusqlite)?);
|
|
}
|
|
values.push(auto_fill_values[row_idx].clone());
|
|
let params: Vec<&dyn rusqlite::ToSql> =
|
|
values.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
|
|
insert_stmt
|
|
.execute(rusqlite::params_from_iter(params))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
row_idx += 1;
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
|
|
tx.execute(
|
|
&format!(
|
|
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
|
VALUES (?1, ?2, ?3);"
|
|
),
|
|
[table, column, ty.keyword()],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
};
|
|
|
|
rebuild_table_with_copy(conn, table, &new_schema, copy_data, metadata_updates)?;
|
|
|
|
let description = do_describe_table(conn, table)?;
|
|
let mut client_side_notes = Vec::new();
|
|
if row_count > 0 {
|
|
client_side_notes.push(format_auto_fill_add_note(ty, row_count));
|
|
}
|
|
Ok(AddColumnResult {
|
|
description,
|
|
client_side_notes,
|
|
})
|
|
}
|
|
|
|
/// Add a plain column whose constraints `ALTER TABLE ADD
|
|
/// COLUMN` cannot express — `UNIQUE`, or `NOT NULL` with no
|
|
/// default — through the rebuild primitive (ADR-0029 §6). A
|
|
/// pre-flight check refuses, with a friendly message, the
|
|
/// cases the table's existing rows would violate.
|
|
fn do_add_constrained_column_via_rebuild(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
spec: &ColumnSpec,
|
|
) -> Result<AddColumnResult, DbError> {
|
|
let old_schema = read_schema(conn, table)?;
|
|
if old_schema.columns.iter().any(|c| c.name == spec.name) {
|
|
return Err(DbError::Unsupported(format!(
|
|
"column `{table}.{}` already exists.",
|
|
spec.name,
|
|
)));
|
|
}
|
|
let row_count = count_rows(conn, table)?;
|
|
|
|
// ADR-0029 §6 pre-flight refusals — caught before any SQL
|
|
// write, surfaced as friendly messages.
|
|
if spec.not_null && spec.default.is_none() && row_count > 0 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"adding the NOT NULL column `{}` to `{table}`, which already \
|
|
has {row_count} row(s), needs a `default` — every existing \
|
|
row would otherwise be null.",
|
|
spec.name,
|
|
)));
|
|
}
|
|
if spec.unique && spec.default.is_some() && row_count > 1 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"adding the UNIQUE column `{}` with a default to `{table}` \
|
|
would give all {row_count} existing rows the same value, \
|
|
breaking uniqueness.",
|
|
spec.name,
|
|
)));
|
|
}
|
|
|
|
// Append the new column to the schema; the rebuild's
|
|
// column-by-name copy leaves it at its DEFAULT (or NULL).
|
|
let mut new_schema = old_schema.clone();
|
|
new_schema.columns.push(ReadColumn {
|
|
name: spec.name.clone(),
|
|
sqlite_type: spec.ty.sqlite_strict_type().to_string(),
|
|
notnull: spec.not_null,
|
|
primary_key: false,
|
|
unique: spec.unique,
|
|
default_sql: default_sql_literal(spec)?,
|
|
check: None,
|
|
user_type: Some(spec.ty),
|
|
});
|
|
// The CHECK is compiled against the post-add schema, so it
|
|
// may reference the new column itself.
|
|
let check_sql = spec
|
|
.check
|
|
.as_ref()
|
|
.map(|e| compile_check_sql(e, &new_schema));
|
|
if let Some(last) = new_schema.columns.last_mut() {
|
|
last.check.clone_from(&check_sql);
|
|
}
|
|
|
|
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
|
|
insert_column_metadata(tx, table, &spec.name, spec.ty, check_sql.as_deref())?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
};
|
|
|
|
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
|
|
|
|
Ok(AddColumnResult {
|
|
description: do_describe_table(conn, table)?,
|
|
client_side_notes: Vec::new(),
|
|
})
|
|
}
|
|
|
|
// =================================================================
|
|
// add constraint / drop constraint (ADR-0029 §2.2 / §5 / §9)
|
|
// =================================================================
|
|
|
|
/// Add a column-level constraint to an existing column
|
|
/// (ADR-0029 §2.2). Steps: resolve the column; apply the §9
|
|
/// redundant-on-PK and §6 default-on-auto-generated refusals;
|
|
/// run the §5 dry-run for the constraints existing data can
|
|
/// violate (`not null` / `unique` / `check`); then apply the
|
|
/// constraint through the rebuild-table primitive — SQLite's
|
|
/// `ALTER TABLE` cannot add these to an existing column.
|
|
fn do_add_constraint(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
column: &str,
|
|
constraint: &Constraint,
|
|
) -> Result<TableDescription, DbError> {
|
|
let old_schema = read_schema(conn, table)?;
|
|
let (col_is_pk, col_user_type) = {
|
|
let col = old_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {table}.{column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
(col.primary_key, col.user_type)
|
|
};
|
|
let single_column_pk = old_schema.primary_key.len() == 1;
|
|
|
|
// ADR-0029 §9 — a constraint the primary key already
|
|
// implies is a friendly refusal, not a silent no-op.
|
|
match constraint {
|
|
Constraint::NotNull if col_is_pk => {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is a primary-key column, so it is already \
|
|
NOT NULL — there is no constraint to add."
|
|
)));
|
|
}
|
|
Constraint::Unique if col_is_pk && single_column_pk => {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is a single-column primary key, so it is \
|
|
already UNIQUE — there is no constraint to add."
|
|
)));
|
|
}
|
|
// ADR-0029 §6 — an auto-generated column fills its own
|
|
// values, so a `default` would be a second, ambiguous
|
|
// source of "the value when none is given".
|
|
Constraint::Default(_)
|
|
if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) =>
|
|
{
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is a {ty} column — it auto-fills its own \
|
|
values, so it cannot also carry a `default`.",
|
|
ty = col_user_type.expect("matched Some above").keyword(),
|
|
)));
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Compile the CHECK once — reused by the dry-run predicate
|
|
// and the column DDL / metadata write.
|
|
let check_sql: Option<String> = match constraint {
|
|
Constraint::Check(expr) => Some(compile_check_sql(expr, &old_schema)),
|
|
_ => None,
|
|
};
|
|
|
|
// ADR-0029 §5 — refuse, before any SQL write, when the
|
|
// existing rows violate the constraint. `default` never
|
|
// touches existing rows, so it skips the dry-run.
|
|
let refusal = match constraint {
|
|
Constraint::NotNull => dry_run_not_null(conn, &old_schema, table, column)?,
|
|
Constraint::Unique => dry_run_unique(conn, &old_schema, table, column)?,
|
|
Constraint::Check(_) => dry_run_check(
|
|
conn,
|
|
&old_schema,
|
|
table,
|
|
column,
|
|
check_sql.as_deref().expect("check_sql set for a Check constraint"),
|
|
)?,
|
|
Constraint::Default(_) => None,
|
|
};
|
|
if let Some(message) = refusal {
|
|
return Err(DbError::Unsupported(message));
|
|
}
|
|
|
|
// Build the post-add schema: the target column gains the
|
|
// constraint; every other column carries over unchanged.
|
|
let mut new_schema = old_schema.clone();
|
|
{
|
|
let target = new_schema
|
|
.columns
|
|
.iter_mut()
|
|
.find(|c| c.name == column)
|
|
.expect("column existence checked above");
|
|
match constraint {
|
|
Constraint::NotNull => target.notnull = true,
|
|
Constraint::Unique => target.unique = true,
|
|
Constraint::Default(value) => {
|
|
let ty = target.user_type.ok_or_else(|| {
|
|
DbError::Unsupported(format!(
|
|
"`{table}.{column}` has no user-type metadata; \
|
|
cannot bind a default value."
|
|
))
|
|
})?;
|
|
target.default_sql = Some(value_to_default_sql(value, column, ty)?);
|
|
}
|
|
Constraint::Check(_) => target.check.clone_from(&check_sql),
|
|
}
|
|
}
|
|
|
|
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
|
|
// Only CHECK needs a metadata write — NOT NULL / UNIQUE /
|
|
// DEFAULT are recoverable from the engine's own catalog
|
|
// (ADR-0029 §7).
|
|
if matches!(constraint, Constraint::Check(_)) {
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {META_TABLE} SET check_expr = ?1 \
|
|
WHERE table_name = ?2 AND column_name = ?3;"
|
|
),
|
|
rusqlite::params![check_sql, table, column],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
};
|
|
|
|
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
|
|
do_describe_table(conn, table)
|
|
}
|
|
|
|
/// Remove a column-level constraint from an existing column
|
|
/// (ADR-0029 §2.2). Removing a constraint cannot violate the
|
|
/// data, so there is no dry-run; the §9 PK-implied refusals
|
|
/// still apply, and dropping a constraint the column does not
|
|
/// carry is itself a friendly refusal.
|
|
fn do_drop_constraint(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
column: &str,
|
|
kind: ConstraintKind,
|
|
) -> Result<TableDescription, DbError> {
|
|
let old_schema = read_schema(conn, table)?;
|
|
let (col_is_pk, present) = {
|
|
let col = old_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {table}.{column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
let present = match kind {
|
|
ConstraintKind::NotNull => col.notnull,
|
|
ConstraintKind::Unique => col.unique,
|
|
ConstraintKind::Default => col.default_sql.is_some(),
|
|
ConstraintKind::Check => col.check.is_some(),
|
|
};
|
|
(col.primary_key, present)
|
|
};
|
|
let single_column_pk = old_schema.primary_key.len() == 1;
|
|
|
|
// ADR-0029 §9 — the primary key still enforces these, so
|
|
// there is nothing for `drop constraint` to remove.
|
|
match kind {
|
|
ConstraintKind::NotNull if col_is_pk => {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is a primary-key column — its NOT NULL is \
|
|
enforced by the primary key and cannot be dropped."
|
|
)));
|
|
}
|
|
ConstraintKind::Unique if col_is_pk && single_column_pk => {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is a single-column primary key — its \
|
|
UNIQUE is enforced by the primary key and cannot be dropped."
|
|
)));
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if !present {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` has no {kind} constraint to drop.",
|
|
kind = kind.label(),
|
|
)));
|
|
}
|
|
|
|
let mut new_schema = old_schema.clone();
|
|
{
|
|
let target = new_schema
|
|
.columns
|
|
.iter_mut()
|
|
.find(|c| c.name == column)
|
|
.expect("column existence checked above");
|
|
match kind {
|
|
ConstraintKind::NotNull => target.notnull = false,
|
|
ConstraintKind::Unique => target.unique = false,
|
|
ConstraintKind::Default => target.default_sql = None,
|
|
ConstraintKind::Check => target.check = None,
|
|
}
|
|
}
|
|
|
|
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
|
|
if kind == ConstraintKind::Check {
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {META_TABLE} SET check_expr = NULL \
|
|
WHERE table_name = ?1 AND column_name = ?2;"
|
|
),
|
|
rusqlite::params![table, column],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
};
|
|
|
|
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)?;
|
|
do_describe_table(conn, table)
|
|
}
|
|
|
|
/// A row's primary-key cell values paired with its value in
|
|
/// the column under test — the unit an ADR-0029 §5 dry-run
|
|
/// scans.
|
|
type DryRunRow = (Vec<rusqlite::types::Value>, rusqlite::types::Value);
|
|
|
|
/// Read the primary-key cell values of every row of `table`
|
|
/// matching `where_sql`, paired with the row's value in
|
|
/// `column`. The shared read behind the three ADR-0029 §5
|
|
/// dry-runs; `where_sql` is built from `quote_ident`-quoted
|
|
/// names and `compile_check_sql` output, never raw user text.
|
|
fn read_constraint_dry_run_rows(
|
|
conn: &Connection,
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
column: &str,
|
|
where_sql: &str,
|
|
) -> Result<Vec<DryRunRow>, DbError> {
|
|
use rusqlite::types::Value as RV;
|
|
let pk_columns = &schema.primary_key;
|
|
let mut select_idents: Vec<String> =
|
|
pk_columns.iter().map(|c| quote_ident(c)).collect();
|
|
select_idents.push(quote_ident(column));
|
|
let sql = format!(
|
|
"SELECT {cols} FROM {tbl} WHERE {pred};",
|
|
cols = select_idents.join(", "),
|
|
tbl = quote_ident(table),
|
|
pred = where_sql,
|
|
);
|
|
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
let pk_count = pk_columns.len();
|
|
let mut out = Vec::new();
|
|
let mut rows = stmt.query([]).map_err(DbError::from_rusqlite)?;
|
|
while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? {
|
|
let mut pk_values: Vec<RV> = Vec::with_capacity(pk_count);
|
|
for i in 0..pk_count {
|
|
pk_values.push(r.get(i).map_err(DbError::from_rusqlite)?);
|
|
}
|
|
let target: RV = r.get(pk_count).map_err(DbError::from_rusqlite)?;
|
|
out.push((pk_values, target));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Header cells identifying an offending row in a §5 dry-run
|
|
/// table — the primary-key columns. The DSL always creates a
|
|
/// primary key, so the no-PK fallback (the constrained column
|
|
/// itself) is defensive only.
|
|
fn dry_run_id_headers(schema: &ReadSchema, column: &str) -> Vec<String> {
|
|
if schema.primary_key.is_empty() {
|
|
vec![column.to_string()]
|
|
} else {
|
|
pk_header_cells(&schema.primary_key)
|
|
}
|
|
}
|
|
|
|
fn dry_run_id_alignments(schema: &ReadSchema) -> Vec<Alignment> {
|
|
if schema.primary_key.is_empty() {
|
|
vec![Alignment::Left]
|
|
} else {
|
|
pk_header_alignments(&schema.primary_key, schema)
|
|
}
|
|
}
|
|
|
|
fn dry_run_id_cells(
|
|
schema: &ReadSchema,
|
|
pk_values: &[rusqlite::types::Value],
|
|
target: &rusqlite::types::Value,
|
|
) -> Vec<String> {
|
|
if schema.primary_key.is_empty() {
|
|
vec![render_value(target)]
|
|
} else {
|
|
pk_value_cells(pk_values)
|
|
}
|
|
}
|
|
|
|
/// Assemble a §5 dry-run refusal: a summary line above the
|
|
/// pretty-printed table of offending rows.
|
|
fn render_constraint_dry_run(
|
|
summary: String,
|
|
headers: &[String],
|
|
alignments: &[Alignment],
|
|
rows: Vec<Vec<String>>,
|
|
) -> String {
|
|
let mut out = format!("{summary}\n\n");
|
|
for line in render_diagnostic_table(headers, &rows, alignments) {
|
|
out.push_str(&line);
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// ADR-0029 §5 dry-run for `add constraint not null`: any row
|
|
/// whose target column holds `NULL` violates the constraint.
|
|
fn dry_run_not_null(
|
|
conn: &Connection,
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
column: &str,
|
|
) -> Result<Option<String>, DbError> {
|
|
let rows = read_constraint_dry_run_rows(
|
|
conn,
|
|
schema,
|
|
table,
|
|
column,
|
|
&format!("{col} IS NULL", col = quote_ident(column)),
|
|
)?;
|
|
if rows.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let total = rows.len();
|
|
let headers = dry_run_id_headers(schema, column);
|
|
let alignments = dry_run_id_alignments(schema);
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for (pk, target) in rows.iter().take(visible) {
|
|
out_rows.push(dry_run_id_cells(schema, pk, target));
|
|
}
|
|
if total > visible {
|
|
out_rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
Ok(Some(render_constraint_dry_run(
|
|
crate::t!(
|
|
"db.diagnostic.add_not_null_summary",
|
|
table = table,
|
|
column = column,
|
|
total = total,
|
|
),
|
|
&headers,
|
|
&alignments,
|
|
out_rows,
|
|
)))
|
|
}
|
|
|
|
/// ADR-0029 §5 dry-run for `add constraint unique`: any
|
|
/// non-`NULL` value shared by two or more rows collides
|
|
/// (SQL's "NULLs are distinct" rule means `NULL`s never do).
|
|
fn dry_run_unique(
|
|
conn: &Connection,
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
column: &str,
|
|
) -> Result<Option<String>, DbError> {
|
|
use std::collections::BTreeMap;
|
|
|
|
let rows = read_constraint_dry_run_rows(
|
|
conn,
|
|
schema,
|
|
table,
|
|
column,
|
|
&format!("{col} IS NOT NULL", col = quote_ident(column)),
|
|
)?;
|
|
let mut groups: BTreeMap<String, Vec<DryRunRow>> = BTreeMap::new();
|
|
for (pk, target) in rows {
|
|
groups
|
|
.entry(render_value(&target))
|
|
.or_default()
|
|
.push((pk, target));
|
|
}
|
|
let collisions: Vec<(String, Vec<DryRunRow>)> = groups
|
|
.into_iter()
|
|
.filter(|(_, members)| members.len() >= 2)
|
|
.collect();
|
|
if collisions.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let total = collisions.len();
|
|
let pk_label = if schema.primary_key.is_empty() {
|
|
column.to_string()
|
|
} else {
|
|
schema.primary_key.join(", ")
|
|
};
|
|
let headers = vec![
|
|
crate::t!("db.diagnostic.header_value"),
|
|
crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
|
|
];
|
|
let alignments = vec![Alignment::Left, Alignment::Left];
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for (value, members) in collisions.iter().take(visible) {
|
|
let ids = members
|
|
.iter()
|
|
.take(5)
|
|
.map(|(pk, target)| {
|
|
if schema.primary_key.is_empty() {
|
|
render_value(target)
|
|
} else {
|
|
pk_value_cells_inline(pk)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let ids = if members.len() > 5 {
|
|
format!("{ids}, …")
|
|
} else {
|
|
ids
|
|
};
|
|
out_rows.push(vec![value.clone(), ids]);
|
|
}
|
|
if total > visible {
|
|
out_rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
Ok(Some(render_constraint_dry_run(
|
|
crate::t!(
|
|
"db.diagnostic.add_unique_summary",
|
|
table = table,
|
|
column = column,
|
|
total = total,
|
|
),
|
|
&headers,
|
|
&alignments,
|
|
out_rows,
|
|
)))
|
|
}
|
|
|
|
/// ADR-0029 §5 dry-run for `add constraint check`: any row for
|
|
/// which the compiled `check_sql` is definitively false
|
|
/// violates the constraint. (`NOT (expr)` is true only when
|
|
/// `expr` is false — a `NULL` result, which the engine's CHECK
|
|
/// also tolerates, does not select the row.)
|
|
fn dry_run_check(
|
|
conn: &Connection,
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
column: &str,
|
|
check_sql: &str,
|
|
) -> Result<Option<String>, DbError> {
|
|
let rows = read_constraint_dry_run_rows(
|
|
conn,
|
|
schema,
|
|
table,
|
|
column,
|
|
&format!("NOT ({check_sql})"),
|
|
)?;
|
|
if rows.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let total = rows.len();
|
|
let mut headers = dry_run_id_headers(schema, column);
|
|
headers.push(crate::t!("db.diagnostic.header_value"));
|
|
let mut alignments = dry_run_id_alignments(schema);
|
|
alignments.push(Alignment::Left);
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut out_rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for (pk, target) in rows.iter().take(visible) {
|
|
let mut cells = dry_run_id_cells(schema, pk, target);
|
|
cells.push(render_value(target));
|
|
out_rows.push(cells);
|
|
}
|
|
if total > visible {
|
|
out_rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
Ok(Some(render_constraint_dry_run(
|
|
crate::t!(
|
|
"db.diagnostic.add_check_summary",
|
|
table = table,
|
|
column = column,
|
|
total = total,
|
|
rule = check_sql,
|
|
),
|
|
&headers,
|
|
&alignments,
|
|
out_rows,
|
|
)))
|
|
}
|
|
|
|
/// Generate `count` shortid values that don't collide with each
|
|
/// other or with `existing` (a slice of currently-stored
|
|
/// shortid values, used during change-column-to-shortid). Up to
|
|
/// 5 retries per cell per ADR-0018 §3.
|
|
fn generate_shortid_batch(
|
|
count: usize,
|
|
existing: &[String],
|
|
) -> Result<Vec<rusqlite::types::Value>, DbError> {
|
|
use std::collections::HashSet;
|
|
let mut taken: HashSet<String> = existing.iter().cloned().collect();
|
|
let mut out: Vec<rusqlite::types::Value> = Vec::with_capacity(count);
|
|
for _ in 0..count {
|
|
let mut generated: Option<String> = None;
|
|
for _ in 0..5 {
|
|
let candidate = shortid::generate();
|
|
if !taken.contains(&candidate) {
|
|
taken.insert(candidate.clone());
|
|
generated = Some(candidate);
|
|
break;
|
|
}
|
|
}
|
|
match generated {
|
|
Some(v) => out.push(rusqlite::types::Value::Text(v)),
|
|
None => {
|
|
return Err(DbError::Unsupported(
|
|
"could not generate a unique shortid after 5 attempts; \
|
|
this typically indicates a generator-state issue, not \
|
|
a recoverable user error."
|
|
.to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `[client-side]` note line for `add column T: x (serial|shortid)`
|
|
/// on a non-empty table per ADR-0018 §9.
|
|
fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String {
|
|
match ty {
|
|
Type::Serial => {
|
|
crate::t!("client_side.auto_fill_add_serial", count = row_count)
|
|
}
|
|
Type::ShortId => {
|
|
crate::t!("client_side.auto_fill_add_shortid", count = row_count)
|
|
}
|
|
_ => unreachable!("called only for serial/shortid"),
|
|
}
|
|
}
|
|
|
|
/// Drop a column from a table.
|
|
///
|
|
/// Uses SQLite's native `ALTER TABLE … DROP COLUMN`
|
|
/// (available since SQLite 3.35) so we get the engine's
|
|
/// constraint checks for free; SQLite refuses if the column
|
|
/// is part of the PK, has a UNIQUE constraint, is referenced
|
|
/// in a CHECK, or is used in an FK. In addition we run two
|
|
/// up-front checks so the user gets friendly messages
|
|
/// before SQLite refuses:
|
|
///
|
|
/// - Refuse PK columns (the dominant case the user might
|
|
/// try).
|
|
/// - Refuse columns involved in a declared relationship
|
|
/// (per `__rdbms_playground_relationships`). Drop the
|
|
/// relationship first.
|
|
fn do_drop_column(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
column: &str,
|
|
cascade: bool,
|
|
) -> Result<DropColumnResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
let col_info = schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {table}.{column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
if col_info.primary_key {
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot drop primary-key column `{table}.{column}`. \
|
|
Drop the table or change the primary key first."
|
|
)));
|
|
}
|
|
let rel_count: i64 = conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT COUNT(*) FROM {REL_TABLE} \
|
|
WHERE (parent_table = ?1 AND parent_column = ?2) \
|
|
OR (child_table = ?1 AND child_column = ?2);"
|
|
),
|
|
rusqlite::params![table, column],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if rel_count > 0 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot drop `{table}.{column}` while a relationship \
|
|
references it; drop the relationship first."
|
|
)));
|
|
}
|
|
|
|
// Indexes covering this column (ADR-0025). Without
|
|
// `--cascade` a covered column is refused; with it, the
|
|
// covering indexes are dropped alongside the column.
|
|
let covering: Vec<IndexInfo> = read_table_indexes(conn, table)?
|
|
.into_iter()
|
|
.filter(|i| i.columns.iter().any(|c| c == column))
|
|
.collect();
|
|
if !covering.is_empty() && !cascade {
|
|
let names = covering
|
|
.iter()
|
|
.map(|i| format!("`{}`", i.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot drop `{table}.{column}` while an index covers \
|
|
it ({names}); drop the index first, or pass `--cascade` \
|
|
to drop the covering indexes too."
|
|
)));
|
|
}
|
|
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
// Drop covering indexes first — the engine refuses
|
|
// DROP COLUMN on an indexed column otherwise. `covering`
|
|
// is empty unless `--cascade` was given (the refusal above).
|
|
for index in &covering {
|
|
tx.execute_batch(&format!(
|
|
"DROP INDEX {ident};",
|
|
ident = quote_ident(&index.name)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
let ddl = format!(
|
|
"ALTER TABLE {tbl} DROP COLUMN {col};",
|
|
tbl = quote_ident(table),
|
|
col = quote_ident(column),
|
|
);
|
|
debug!(ddl = %ddl, "drop_column");
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
tx.execute(
|
|
&format!(
|
|
"DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;"
|
|
),
|
|
[table, column],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let description = do_describe_table(conn, table)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(DropColumnResult {
|
|
description,
|
|
dropped_indexes: covering.into_iter().map(|i| i.name).collect(),
|
|
})
|
|
}
|
|
|
|
/// Rename a column.
|
|
///
|
|
/// Uses SQLite's native `ALTER TABLE … RENAME COLUMN`
|
|
/// (available since SQLite 3.25), which automatically
|
|
/// updates references in views, triggers, and FK
|
|
/// declarations on other tables. We mirror the rename into
|
|
/// our two metadata tables so they don't drift.
|
|
fn do_rename_column(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
old: &str,
|
|
new: &str,
|
|
) -> Result<TableDescription, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
if !schema.columns.iter().any(|c| c.name == old) {
|
|
return Err(DbError::Sqlite {
|
|
message: format!("no such column: {table}.{old}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
});
|
|
}
|
|
if old == new {
|
|
// Nothing to do; refusing keeps behaviour
|
|
// predictable rather than appearing to "succeed"
|
|
// with no effect.
|
|
return Err(DbError::Unsupported(format!(
|
|
"rename: new name is identical to the existing one (`{old}`)."
|
|
)));
|
|
}
|
|
if schema.columns.iter().any(|c| c.name == new) {
|
|
return Err(DbError::Unsupported(format!(
|
|
"column `{table}.{new}` already exists; pick a different name."
|
|
)));
|
|
}
|
|
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let ddl = format!(
|
|
"ALTER TABLE {tbl} RENAME COLUMN {old_c} TO {new_c};",
|
|
tbl = quote_ident(table),
|
|
old_c = quote_ident(old),
|
|
new_c = quote_ident(new),
|
|
);
|
|
debug!(ddl = %ddl, "rename_column");
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
|
|
// Mirror the rename into __rdbms_playground_columns.
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {META_TABLE} SET column_name = ?1 \
|
|
WHERE table_name = ?2 AND column_name = ?3;"
|
|
),
|
|
[new, table, old],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
// Mirror into __rdbms_playground_relationships on
|
|
// BOTH sides — a column may be the parent endpoint or
|
|
// the child endpoint (or, for self-referencing tables,
|
|
// both).
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {REL_TABLE} SET parent_column = ?1 \
|
|
WHERE parent_table = ?2 AND parent_column = ?3;"
|
|
),
|
|
[new, table, old],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {REL_TABLE} SET child_column = ?1 \
|
|
WHERE child_table = ?2 AND child_column = ?3;"
|
|
),
|
|
[new, table, old],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
let description = do_describe_table(conn, table)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(description)
|
|
}
|
|
|
|
/// Change a column's type.
|
|
///
|
|
/// Change a column's user-facing type, per ADR-0017's per-cell
|
|
/// transformer model.
|
|
///
|
|
/// SQLite's `ALTER TABLE` cannot change column types, so this
|
|
/// routes through the rebuild-table primitive (ADR-0013) with a
|
|
/// row-by-row transformation step inserted between read-out and
|
|
/// write-back.
|
|
///
|
|
/// Refusal preconditions (in order):
|
|
///
|
|
/// 1. Target is `serial` (auto-increment is create-table-only).
|
|
/// 2. Source ↔ target is statically refused per the matrix
|
|
/// (same-type, blob, date↔datetime, undefined cross-domain).
|
|
/// 3. Column is the *child* side of any relationship (outbound
|
|
/// FK) — drop the relationship first.
|
|
/// 4. Column has any inbound FK (parent side) and the new type
|
|
/// would change `fk_target_type()` (ADR-0017 §4.1) — which
|
|
/// means referencing FK columns in other tables would need a
|
|
/// cascading type change v1 doesn't perform.
|
|
///
|
|
/// Then per the mode:
|
|
///
|
|
/// - `Default`: dry-run; refuse on any incompatible cell;
|
|
/// refuse on any lossy cell (with a hint about
|
|
/// `--force-conversion`); enforce uniqueness for PK / shortid
|
|
/// columns; rebuild with transformed values.
|
|
/// - `ForceConversion`: same as Default except lossy cells are
|
|
/// accepted (incompatible cells still refuse).
|
|
/// - `DontConvert`: skip the entire client-side layer, identity
|
|
/// copy via `INSERT…SELECT`; engine-level errors are wrapped
|
|
/// in friendly form (ADR-0002 user-facing posture).
|
|
fn do_change_column_type(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
column: &str,
|
|
ty: Type,
|
|
mode: ChangeColumnMode,
|
|
) -> Result<ChangeColumnTypeResult, DbError> {
|
|
let old_schema = read_schema(conn, table)?;
|
|
let col_info = old_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {table}.{column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
let src_ty = col_info.user_type.ok_or_else(|| {
|
|
DbError::Unsupported(format!(
|
|
"cannot change type of `{table}.{column}`: column has no \
|
|
user-facing type metadata."
|
|
))
|
|
})?;
|
|
|
|
// (1) + (2): static refusals via the matrix.
|
|
if let Some(reason) = type_change::static_refusal(src_ty, ty) {
|
|
return Err(DbError::Unsupported(reason));
|
|
}
|
|
|
|
// (3): outbound FK (column is child side of any relationship).
|
|
let outbound_count: i64 = conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT COUNT(*) FROM {REL_TABLE} \
|
|
WHERE child_table = ?1 AND child_column = ?2;"
|
|
),
|
|
rusqlite::params![table, column],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if outbound_count > 0 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot change type of `{table}.{column}` while a relationship \
|
|
uses it as a foreign key; drop the relationship first."
|
|
)));
|
|
}
|
|
|
|
// (4): inbound FKs (column is parent / target of any FK).
|
|
let inbound_count: i64 = conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT COUNT(*) FROM {REL_TABLE} \
|
|
WHERE parent_table = ?1 AND parent_column = ?2;"
|
|
),
|
|
rusqlite::params![table, column],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if inbound_count > 0 && src_ty.fk_target_type() != ty.fk_target_type() {
|
|
return Err(DbError::Unsupported(format!(
|
|
"`{table}.{column}` is referenced by {inbound_count} relationship(s); \
|
|
changing its type to `{ty}` would change the type that \
|
|
referencing columns require — drop those relationships first \
|
|
or pick a target type whose foreign-key shape matches the \
|
|
current one."
|
|
)));
|
|
}
|
|
|
|
// Build new_schema: same as old, but the target column's
|
|
// user_type / sqlite_type are updated. PK / notnull flags
|
|
// carry over unchanged. UNIQUE handling per ADR-0018 §4:
|
|
//
|
|
// - Non-PK serial / shortid target: gain UNIQUE.
|
|
// - Reverse direction (e.g. serial → int): keep whatever
|
|
// UNIQUE flag was already present (ADR-0018 §4 "leaves
|
|
// UNIQUE in place"), which the clone() above already
|
|
// does.
|
|
let mut new_schema = old_schema.clone();
|
|
{
|
|
let target = new_schema
|
|
.columns
|
|
.iter_mut()
|
|
.find(|c| c.name == column)
|
|
.expect("column existence checked above");
|
|
target.user_type = Some(ty);
|
|
target.sqlite_type = ty.sqlite_strict_type().to_string();
|
|
if matches!(ty, Type::Serial | Type::ShortId) && !target.primary_key {
|
|
target.unique = true;
|
|
// Auto-generated columns are non-null by contract
|
|
// (ADR-0018 §3). Set the flag here so schema_to_ddl
|
|
// emits NOT NULL during the rebuild.
|
|
target.notnull = true;
|
|
}
|
|
}
|
|
|
|
let ty_keyword = ty.keyword();
|
|
let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> {
|
|
tx.execute(
|
|
&format!(
|
|
"UPDATE {META_TABLE} SET user_type = ?1 \
|
|
WHERE table_name = ?2 AND column_name = ?3;"
|
|
),
|
|
[ty_keyword, table, column],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
};
|
|
|
|
let client_side = match mode {
|
|
ChangeColumnMode::DontConvert => {
|
|
// Skip the client-side layer entirely. The engine's
|
|
// STRICT typing will accept or refuse cells; we wrap
|
|
// any error in a friendly message (ADR-0017 §5,
|
|
// ADR-0002 user-facing posture).
|
|
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)
|
|
.map_err(|e| {
|
|
let ctx = crate::friendly::TranslateContext {
|
|
operation: Some(crate::friendly::Operation::ChangeColumnType),
|
|
table: Some(table.to_string()),
|
|
column: Some(column.to_string()),
|
|
src_type: Some(src_ty),
|
|
target_type: Some(ty),
|
|
..crate::friendly::TranslateContext::default()
|
|
};
|
|
let rendered =
|
|
crate::friendly::translate_error(&e, &ctx).render();
|
|
DbError::Unsupported(rendered)
|
|
})?;
|
|
None
|
|
}
|
|
ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some(
|
|
run_change_column_with_dry_run(
|
|
conn,
|
|
table,
|
|
column,
|
|
src_ty,
|
|
ty,
|
|
mode,
|
|
&old_schema,
|
|
&new_schema,
|
|
metadata_updates,
|
|
)?,
|
|
),
|
|
};
|
|
// Client-side note fires when at least one cell was
|
|
// materially transformed (ADR-0017 §6) OR at least one
|
|
// null cell was auto-filled (ADR-0018 §9). A pure metadata
|
|
// change (zero non-identity outcomes, no auto-fill) emits
|
|
// no note.
|
|
let client_side = client_side.filter(|note| note.transformed > 0 || note.auto_filled > 0);
|
|
|
|
let description = do_describe_table(conn, table)?;
|
|
Ok(ChangeColumnTypeResult {
|
|
description,
|
|
client_side,
|
|
})
|
|
}
|
|
|
|
/// Read every row from the source table, classify the target
|
|
/// column's cell via the transformer matrix, refuse on
|
|
/// incompatibles or lossy (per `mode`), check uniqueness for
|
|
/// PK / shortid, and finally rebuild the table with the
|
|
/// transformed values bound row-by-row.
|
|
///
|
|
/// Returns the per-row counts feeding the `[client-side]`
|
|
/// success note (ADR-0017 §6).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_change_column_with_dry_run<M>(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
src_ty: Type,
|
|
target_ty: Type,
|
|
mode: ChangeColumnMode,
|
|
old_schema: &ReadSchema,
|
|
new_schema: &ReadSchema,
|
|
metadata_updates: M,
|
|
) -> Result<ClientSideNote, DbError>
|
|
where
|
|
M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
|
|
{
|
|
use rusqlite::types::Value as RV;
|
|
use type_change::CellOutcome;
|
|
|
|
let pk_columns: Vec<String> = old_schema
|
|
.columns
|
|
.iter()
|
|
.filter(|c| c.primary_key)
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
if pk_columns.is_empty() {
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot run change-column dry run on `{table}` — table has no \
|
|
primary key; use `--dont-convert` to bypass the client-side \
|
|
layer."
|
|
)));
|
|
}
|
|
|
|
// Read every row's data: the target column's value, plus the
|
|
// PK column values (used for diagnostic identifiers and for
|
|
// re-binding the row during rebuild).
|
|
let all_columns: Vec<String> = old_schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
let target_idx = all_columns
|
|
.iter()
|
|
.position(|c| c == column)
|
|
.expect("column existence checked above");
|
|
let pk_indices: Vec<usize> = pk_columns
|
|
.iter()
|
|
.map(|pk| all_columns.iter().position(|c| c == pk).expect("pk in cols"))
|
|
.collect();
|
|
|
|
let select_cols = all_columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let order_by = pk_columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let select_sql = format!(
|
|
"SELECT {select_cols} FROM {tbl} ORDER BY {order_by};",
|
|
tbl = quote_ident(table),
|
|
);
|
|
|
|
let mut rows: Vec<Vec<RV>> = Vec::new();
|
|
{
|
|
let mut stmt = conn.prepare(&select_sql).map_err(DbError::from_rusqlite)?;
|
|
let mut sql_rows = stmt.query([]).map_err(DbError::from_rusqlite)?;
|
|
while let Some(r) = sql_rows.next().map_err(DbError::from_rusqlite)? {
|
|
let mut row: Vec<RV> = Vec::with_capacity(all_columns.len());
|
|
for i in 0..all_columns.len() {
|
|
let v: RV = r.get(i).map_err(DbError::from_rusqlite)?;
|
|
row.push(v);
|
|
}
|
|
rows.push(row);
|
|
}
|
|
}
|
|
|
|
// Classify every row's target cell. Null cells targeting
|
|
// an auto-generated type get a placeholder outcome here
|
|
// and are filled in below once the full set of non-null
|
|
// values is known (so serial sequencing can start at
|
|
// `MAX + 1`, and shortid generation can avoid collisions
|
|
// against existing values).
|
|
let auto_filling = matches!(target_ty, Type::Serial | Type::ShortId);
|
|
let mut outcomes: Vec<Outcome> = Vec::with_capacity(rows.len());
|
|
for row in &rows {
|
|
let pk_values: Vec<RV> = pk_indices.iter().map(|i| row[*i].clone()).collect();
|
|
let original = row[target_idx].clone();
|
|
let (outcome, is_auto_fill) = if auto_filling && matches!(original, RV::Null) {
|
|
// Placeholder; replaced after the loop.
|
|
(CellOutcome::Clean(RV::Null), true)
|
|
} else {
|
|
(type_change::transform_cell(src_ty, target_ty, &original), false)
|
|
};
|
|
outcomes.push(Outcome {
|
|
pk_values,
|
|
original,
|
|
outcome,
|
|
is_auto_fill,
|
|
});
|
|
}
|
|
|
|
// Auto-fill pass for serial / shortid targets per ADR-0018
|
|
// §3 / §7. Runs before the incompatible / lossy / collision
|
|
// checks so the filled values participate in those checks
|
|
// as if they were ordinary classified outcomes.
|
|
if auto_filling {
|
|
fill_auto_generated_cells(target_ty, &mut outcomes)?;
|
|
}
|
|
|
|
// Refuse on incompatibles.
|
|
let incompatibles: Vec<&Outcome> = outcomes
|
|
.iter()
|
|
.filter(|o| matches!(o.outcome, CellOutcome::Incompatible { .. }))
|
|
.collect();
|
|
if !incompatibles.is_empty() {
|
|
return Err(DbError::Unsupported(render_incompatible_diagnostic(
|
|
table,
|
|
column,
|
|
src_ty,
|
|
target_ty,
|
|
&pk_columns,
|
|
old_schema,
|
|
&incompatibles,
|
|
)));
|
|
}
|
|
|
|
// Refuse on lossy unless --force-conversion.
|
|
let lossies: Vec<&Outcome> = outcomes
|
|
.iter()
|
|
.filter(|o| matches!(o.outcome, CellOutcome::Lossy { .. }))
|
|
.collect();
|
|
if !lossies.is_empty() && mode != ChangeColumnMode::ForceConversion {
|
|
return Err(DbError::Unsupported(render_lossy_diagnostic(
|
|
table,
|
|
column,
|
|
src_ty,
|
|
target_ty,
|
|
&pk_columns,
|
|
old_schema,
|
|
&lossies,
|
|
)));
|
|
}
|
|
|
|
// Uniqueness check for any target column that will carry
|
|
// a uniqueness constraint in the new schema. ADR-0017 §4.3
|
|
// covered PK + shortid; ADR-0018 §4.3-amendment extends
|
|
// this to "any column that gains a UNIQUE constraint as
|
|
// part of the operation" — i.e., the new schema's target
|
|
// column has primary_key OR unique set.
|
|
let new_target = new_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.expect("target column exists in new schema");
|
|
let uniqueness_required = new_target.primary_key || new_target.unique;
|
|
if uniqueness_required
|
|
&& let Some(error_msg) = check_uniqueness_collisions(
|
|
table,
|
|
column,
|
|
src_ty,
|
|
target_ty,
|
|
&pk_columns,
|
|
old_schema,
|
|
&outcomes,
|
|
)
|
|
{
|
|
return Err(DbError::Unsupported(error_msg));
|
|
}
|
|
|
|
// All cells classified Clean or Lossy-with-force. Build the
|
|
// transformed values list, indexed by row, and rebuild the
|
|
// table with row-by-row binding.
|
|
let transformed_values: Vec<RV> = outcomes
|
|
.iter()
|
|
.map(|o| match &o.outcome {
|
|
CellOutcome::Clean(v) | CellOutcome::Lossy { new: v, .. } => v.clone(),
|
|
CellOutcome::Incompatible { .. } => {
|
|
unreachable!("incompatibles refused above")
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let copy_data = |tx: &rusqlite::Transaction<'_>,
|
|
temp_name: &str,
|
|
_orig: &str|
|
|
-> Result<(), DbError> {
|
|
if rows.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let cols_csv = all_columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let placeholders = (0..all_columns.len())
|
|
.map(|i| format!("?{}", i + 1))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let insert_sql = format!(
|
|
"INSERT INTO {temp} ({cols}) VALUES ({ph});",
|
|
temp = quote_ident(temp_name),
|
|
cols = cols_csv,
|
|
ph = placeholders,
|
|
);
|
|
let mut stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?;
|
|
for (row_idx, row) in rows.iter().enumerate() {
|
|
let mut bound: Vec<RV> = row.clone();
|
|
bound[target_idx] = transformed_values[row_idx].clone();
|
|
let params: Vec<&dyn rusqlite::ToSql> =
|
|
bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
|
|
stmt.execute(rusqlite::params_from_iter(params))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
rebuild_table_with_copy(conn, table, new_schema, copy_data, metadata_updates)?;
|
|
|
|
// Tally counts for the [client-side] note. Three kinds of
|
|
// cells of interest (ADR-0017 §6 + ADR-0018 §9):
|
|
//
|
|
// - auto-filled: original was NULL, target type
|
|
// auto-generates. Counted under `auto_filled`, NOT
|
|
// `transformed` (different user-facing wording).
|
|
// - transformed: original was non-null and the stored
|
|
// value materially differs (storage class change
|
|
// counts).
|
|
// - lossy: subset of transformed; only when
|
|
// --force-conversion was used.
|
|
let mut transformed = 0usize;
|
|
let mut lossy = 0usize;
|
|
let mut auto_filled = 0usize;
|
|
for (idx, o) in outcomes.iter().enumerate() {
|
|
let new = &transformed_values[idx];
|
|
if o.is_auto_fill {
|
|
auto_filled += 1;
|
|
} else if type_change::is_non_identity(&o.original, new) {
|
|
transformed += 1;
|
|
}
|
|
if matches!(o.outcome, CellOutcome::Lossy { .. }) {
|
|
lossy += 1;
|
|
}
|
|
}
|
|
let auto_fill_kind = if auto_filled > 0 {
|
|
Some(match target_ty {
|
|
Type::Serial => AutoFillKind::Serial,
|
|
Type::ShortId => AutoFillKind::ShortId,
|
|
_ => unreachable!("auto-fill only fires for Serial / ShortId targets"),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
Ok(ClientSideNote {
|
|
transformed,
|
|
lossy,
|
|
auto_filled,
|
|
auto_fill_kind,
|
|
})
|
|
}
|
|
|
|
/// Replace the placeholder `Clean(Null)` outcomes for null
|
|
/// source cells with auto-generated values.
|
|
///
|
|
/// For `Serial` targets: continue the integer sequence from
|
|
/// `MAX(non-null value) + 1`. For `ShortId` targets: generate
|
|
/// fresh shortids that don't collide with existing values or
|
|
/// with one another (5-retry budget per cell, ADR-0018 §3).
|
|
fn fill_auto_generated_cells(
|
|
target_ty: Type,
|
|
outcomes: &mut [Outcome],
|
|
) -> Result<(), DbError> {
|
|
use rusqlite::types::Value as RV;
|
|
use type_change::CellOutcome;
|
|
|
|
match target_ty {
|
|
Type::Serial => {
|
|
let max_existing: i64 = outcomes
|
|
.iter()
|
|
.filter_map(|o| {
|
|
if o.is_auto_fill {
|
|
return None;
|
|
}
|
|
match &o.outcome {
|
|
CellOutcome::Clean(RV::Integer(i)) => Some(*i),
|
|
_ => None,
|
|
}
|
|
})
|
|
.max()
|
|
.unwrap_or(0);
|
|
let mut next = max_existing.saturating_add(1);
|
|
for o in outcomes.iter_mut().filter(|o| o.is_auto_fill) {
|
|
o.outcome = CellOutcome::Clean(RV::Integer(next));
|
|
next = next.saturating_add(1);
|
|
}
|
|
}
|
|
Type::ShortId => {
|
|
let existing: Vec<String> = outcomes
|
|
.iter()
|
|
.filter_map(|o| {
|
|
if o.is_auto_fill {
|
|
return None;
|
|
}
|
|
match &o.outcome {
|
|
CellOutcome::Clean(RV::Text(s)) => Some(s.clone()),
|
|
_ => None,
|
|
}
|
|
})
|
|
.collect();
|
|
let auto_fill_count = outcomes.iter().filter(|o| o.is_auto_fill).count();
|
|
let new_values = generate_shortid_batch(auto_fill_count, &existing)?;
|
|
for (idx, o) in outcomes
|
|
.iter_mut()
|
|
.filter(|o| o.is_auto_fill)
|
|
.enumerate()
|
|
{
|
|
o.outcome = CellOutcome::Clean(new_values[idx].clone());
|
|
}
|
|
}
|
|
_ => {
|
|
// Caller filters to Serial / ShortId; the
|
|
// unreachable branch is defensive.
|
|
unreachable!("fill_auto_generated_cells called with non-auto-gen target");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Maximum diagnostic rows rendered per refusal (ADR-0017 §7).
|
|
/// Rows beyond this collapse into a trailing `… and N more` row.
|
|
const DIAGNOSTIC_ROW_CAP: usize = 100;
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_lossy_diagnostic(
|
|
table: &str,
|
|
column: &str,
|
|
src_ty: Type,
|
|
target_ty: Type,
|
|
pk_columns: &[String],
|
|
old_schema: &ReadSchema,
|
|
lossies: &[&Outcome],
|
|
) -> String {
|
|
let mut headers = pk_header_cells(pk_columns);
|
|
headers.extend([
|
|
crate::t!("db.diagnostic.header_from"),
|
|
crate::t!("db.diagnostic.header_to"),
|
|
crate::t!("db.diagnostic.header_reason"),
|
|
]);
|
|
|
|
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
|
alignments.extend([
|
|
type_change::is_in_matrix_alignment(src_ty),
|
|
type_change::is_in_matrix_alignment(target_ty),
|
|
Alignment::Left,
|
|
]);
|
|
|
|
let total = lossies.len();
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for o in lossies.iter().take(visible) {
|
|
let mut cells = pk_value_cells(&o.pk_values);
|
|
let (new_str, reason) = match &o.outcome {
|
|
type_change::CellOutcome::Lossy { new, reason } => {
|
|
(render_value(new), reason.clone())
|
|
}
|
|
_ => unreachable!("filtered to Lossy"),
|
|
};
|
|
cells.push(render_value(&o.original));
|
|
cells.push(new_str);
|
|
cells.push(reason);
|
|
rows.push(cells);
|
|
}
|
|
if total > visible {
|
|
rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
|
|
let mut out = format!(
|
|
"{}\n\n",
|
|
crate::t!(
|
|
"db.diagnostic.lossy_summary",
|
|
table = table,
|
|
column = column,
|
|
src_ty = src_ty,
|
|
target_ty = target_ty,
|
|
total = total,
|
|
),
|
|
);
|
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
|
out.push_str(&line);
|
|
out.push('\n');
|
|
}
|
|
out.push('\n');
|
|
out.push_str(&crate::t!("db.diagnostic.force_conversion_hint"));
|
|
out
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_incompatible_diagnostic(
|
|
table: &str,
|
|
column: &str,
|
|
src_ty: Type,
|
|
target_ty: Type,
|
|
pk_columns: &[String],
|
|
old_schema: &ReadSchema,
|
|
incompatibles: &[&Outcome],
|
|
) -> String {
|
|
let mut headers = pk_header_cells(pk_columns);
|
|
headers.extend([
|
|
crate::t!("db.diagnostic.header_value"),
|
|
crate::t!("db.diagnostic.header_reason"),
|
|
]);
|
|
|
|
let mut alignments = pk_header_alignments(pk_columns, old_schema);
|
|
alignments.extend([
|
|
type_change::is_in_matrix_alignment(src_ty),
|
|
Alignment::Left,
|
|
]);
|
|
|
|
let total = incompatibles.len();
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for o in incompatibles.iter().take(visible) {
|
|
let reason = match &o.outcome {
|
|
type_change::CellOutcome::Incompatible { reason } => reason.clone(),
|
|
_ => unreachable!("filtered to Incompatible"),
|
|
};
|
|
let mut cells = pk_value_cells(&o.pk_values);
|
|
cells.push(render_value(&o.original));
|
|
cells.push(reason);
|
|
rows.push(cells);
|
|
}
|
|
if total > visible {
|
|
rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
|
|
let mut out = format!(
|
|
"{}\n\n",
|
|
crate::t!(
|
|
"db.diagnostic.incompatible_summary",
|
|
table = table,
|
|
column = column,
|
|
src_ty = src_ty,
|
|
target_ty = target_ty,
|
|
total = total,
|
|
),
|
|
);
|
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
|
out.push_str(&line);
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Detect post-transformation duplicates among `outcomes` and
|
|
/// produce a refusal diagnostic if any exist. Returns `None` if
|
|
/// uniqueness is preserved. Per ADR-0017 §4.3 collisions are
|
|
/// classified incompatible — no `--force-conversion` override.
|
|
fn check_uniqueness_collisions(
|
|
table: &str,
|
|
column: &str,
|
|
src_ty: Type,
|
|
target_ty: Type,
|
|
pk_columns: &[String],
|
|
old_schema: &ReadSchema,
|
|
outcomes: &[Outcome],
|
|
) -> Option<String> {
|
|
use std::collections::BTreeMap;
|
|
|
|
// Keyed by the canonical string form of the transformed
|
|
// value; NULL is excluded from uniqueness collision checks
|
|
// (the new column may carry NULL until C3 lands NOT NULL
|
|
// constraints; treat NULLs as not-colliding).
|
|
let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
|
for (i, o) in outcomes.iter().enumerate() {
|
|
let new = match &o.outcome {
|
|
type_change::CellOutcome::Clean(v) => v,
|
|
type_change::CellOutcome::Lossy { new, .. } => new,
|
|
type_change::CellOutcome::Incompatible { .. } => continue,
|
|
};
|
|
if matches!(new, rusqlite::types::Value::Null) {
|
|
continue;
|
|
}
|
|
groups.entry(render_value(new)).or_default().push(i);
|
|
}
|
|
|
|
let collisions: Vec<(String, Vec<usize>)> = groups
|
|
.into_iter()
|
|
.filter(|(_, idxs)| idxs.len() >= 2)
|
|
.collect();
|
|
if collisions.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let pk_label = pk_columns.join(", ");
|
|
let headers = vec![
|
|
crate::t!("db.diagnostic.header_becomes"),
|
|
crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
|
|
crate::t!("db.diagnostic.header_source_values"),
|
|
];
|
|
|
|
let alignments = vec![
|
|
type_change::is_in_matrix_alignment(target_ty),
|
|
Alignment::Left,
|
|
Alignment::Left,
|
|
];
|
|
|
|
let total = collisions.len();
|
|
let visible = total.min(DIAGNOSTIC_ROW_CAP);
|
|
let mut rows: Vec<Vec<String>> = Vec::with_capacity(visible + 1);
|
|
for (becomes, idxs) in collisions.iter().take(visible) {
|
|
let pks_csv = idxs
|
|
.iter()
|
|
.take(5)
|
|
.map(|i| pk_value_cells_inline(&outcomes[*i].pk_values))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let pks_str = if idxs.len() > 5 {
|
|
format!("{pks_csv}, …")
|
|
} else {
|
|
pks_csv
|
|
};
|
|
let sources_csv = idxs
|
|
.iter()
|
|
.take(5)
|
|
.map(|i| render_value(&outcomes[*i].original))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sources_str = if idxs.len() > 5 {
|
|
format!("{sources_csv}, …")
|
|
} else {
|
|
sources_csv
|
|
};
|
|
rows.push(vec![becomes.clone(), pks_str, sources_str]);
|
|
}
|
|
if total > visible {
|
|
rows.push(more_row(headers.len(), total - visible));
|
|
}
|
|
|
|
// ColumnDescription guarantees old_schema known; not used
|
|
// beyond the §4.3 wording so silence the warning.
|
|
let _ = old_schema;
|
|
|
|
let mut out = format!(
|
|
"{}\n\n",
|
|
crate::t!(
|
|
"db.diagnostic.uniqueness_summary",
|
|
table = table,
|
|
column = column,
|
|
src_ty = src_ty,
|
|
target_ty = target_ty,
|
|
total = total,
|
|
),
|
|
);
|
|
for line in render_diagnostic_table(&headers, &rows, &alignments) {
|
|
out.push_str(&line);
|
|
out.push('\n');
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Outcome record for the dry-run pass. Mirrors the inner type
|
|
/// inside `run_change_column_with_dry_run` so the diagnostic
|
|
/// helpers can take it as a slice.
|
|
struct Outcome {
|
|
pk_values: Vec<rusqlite::types::Value>,
|
|
original: rusqlite::types::Value,
|
|
outcome: type_change::CellOutcome,
|
|
/// `true` when the original cell was NULL and the target
|
|
/// type carries an auto-generation contract; the `outcome`
|
|
/// gets filled in with the generated value during the
|
|
/// post-classification auto-fill pass.
|
|
is_auto_fill: bool,
|
|
}
|
|
|
|
fn pk_header_cells(pk_columns: &[String]) -> Vec<String> {
|
|
pk_columns
|
|
.iter()
|
|
.map(|c| format!("{c} (PK)"))
|
|
.collect()
|
|
}
|
|
|
|
fn pk_header_alignments(pk_columns: &[String], schema: &ReadSchema) -> Vec<Alignment> {
|
|
pk_columns
|
|
.iter()
|
|
.map(|name| {
|
|
schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| &c.name == name)
|
|
.and_then(|c| c.user_type)
|
|
.map_or(Alignment::Left, type_change::is_in_matrix_alignment)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn pk_value_cells(values: &[rusqlite::types::Value]) -> Vec<String> {
|
|
values.iter().map(render_value).collect()
|
|
}
|
|
|
|
/// Single-cell inline rendering of a row's PK values, used in
|
|
/// uniqueness-collision diagnostics where one cell lists many
|
|
/// PK identifiers. Single-PK forms render as bare values (`5`);
|
|
/// compound-PK forms render as tuples (`(1, 2)`).
|
|
fn pk_value_cells_inline(values: &[rusqlite::types::Value]) -> String {
|
|
if values.len() == 1 {
|
|
render_value(&values[0])
|
|
} else {
|
|
let parts: Vec<String> = values.iter().map(render_value).collect();
|
|
format!("({})", parts.join(", "))
|
|
}
|
|
}
|
|
|
|
fn render_value(v: &rusqlite::types::Value) -> String {
|
|
use rusqlite::types::Value as RV;
|
|
match v {
|
|
RV::Null => "(null)".to_string(),
|
|
RV::Integer(i) => i.to_string(),
|
|
RV::Real(r) => format!("{r}"),
|
|
RV::Text(s) => s.clone(),
|
|
RV::Blob(_) => "<blob>".to_string(),
|
|
}
|
|
}
|
|
|
|
fn more_row(width: usize, more: usize) -> Vec<String> {
|
|
let mut row = vec!["…".to_string(); width];
|
|
if let Some(last) = row.last_mut() {
|
|
*last = format!("… and {more} more");
|
|
}
|
|
row
|
|
}
|
|
|
|
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT name FROM sqlite_schema \
|
|
WHERE type = 'table' \
|
|
AND name NOT LIKE 'sqlite_%' \
|
|
AND substr(name, 1, 8) != '__rdbms_' \
|
|
ORDER BY name;",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Internal full schema of a table, sufficient to regenerate
|
|
/// its `CREATE TABLE` statement during the rebuild dance.
|
|
#[derive(Debug, Clone)]
|
|
struct ReadSchema {
|
|
columns: Vec<ReadColumn>,
|
|
primary_key: Vec<String>,
|
|
foreign_keys: Vec<ReadForeignKey>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ReadColumn {
|
|
name: String,
|
|
sqlite_type: String,
|
|
notnull: bool,
|
|
primary_key: bool,
|
|
/// `true` when this column carries a single-column UNIQUE
|
|
/// constraint detected via `pragma_index_list` /
|
|
/// `pragma_index_info` (origin = "u"). PK columns are not
|
|
/// marked unique here even though PK implies UNIQUE — the
|
|
/// `primary_key` flag covers that, and `schema_to_ddl`
|
|
/// avoids double-emitting. Compound UNIQUE is out of scope
|
|
/// for v1 (ADR-0018 OOS-6 / future C3 work).
|
|
unique: bool,
|
|
/// The column's `DEFAULT` expression as SQLite reports it
|
|
/// (`pragma_table_info.dflt_value`) — already a SQL
|
|
/// literal, echoed verbatim by `schema_to_ddl` so the
|
|
/// rebuild dance preserves it (ADR-0029).
|
|
default_sql: Option<String>,
|
|
/// The column's `CHECK` constraint in compiled-SQL form
|
|
/// (ADR-0029 §7), read from the `check_expr` metadata
|
|
/// column — `pragma_table_info` does not expose CHECK.
|
|
/// Echoed verbatim by `schema_to_ddl`.
|
|
check: Option<String>,
|
|
user_type: Option<Type>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ReadForeignKey {
|
|
parent_table: String,
|
|
parent_column: String,
|
|
child_column: String,
|
|
on_delete: ReferentialAction,
|
|
on_update: ReferentialAction,
|
|
}
|
|
|
|
fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
|
|
// Columns + PK from pragma_table_info, joined with our user-type metadata.
|
|
let mut col_stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type, \
|
|
pti.dflt_value, m.check_expr \
|
|
FROM pragma_table_info(?1) AS pti \
|
|
LEFT JOIN {META_TABLE} AS m \
|
|
ON m.table_name = ?1 AND m.column_name = pti.name \
|
|
ORDER BY pti.cid;"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = col_stmt
|
|
.query_map([table], |row| {
|
|
let user_type_kw: Option<String> = row.get(4)?;
|
|
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
|
|
Ok(ReadColumn {
|
|
name: row.get(0)?,
|
|
sqlite_type: row.get(1)?,
|
|
notnull: row.get::<_, i64>(2)? != 0,
|
|
primary_key: row.get::<_, i64>(3)? != 0,
|
|
unique: false, // filled in below from pragma_index_list
|
|
default_sql: row.get(5)?,
|
|
check: row.get(6)?,
|
|
user_type,
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut columns = Vec::new();
|
|
for row in rows {
|
|
columns.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
if columns.is_empty() {
|
|
return Err(DbError::Sqlite {
|
|
message: format!("no such table: {table}"),
|
|
kind: SqliteErrorKind::NoSuchTable,
|
|
});
|
|
}
|
|
let primary_key: Vec<String> = columns
|
|
.iter()
|
|
.filter(|c| c.primary_key)
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
|
|
// Detect single-column UNIQUE constraints (ADR-0018 §4).
|
|
// pragma_index_list returns one row per index; we filter to
|
|
// unique indexes whose origin is "u" (a UNIQUE constraint,
|
|
// as opposed to "pk" or "c"). For each, pragma_index_info
|
|
// gives the constituent column(s); we only mark single-
|
|
// column unique here. Compound UNIQUE is out of scope.
|
|
let unique_columns = read_unique_columns(conn, table)?;
|
|
for col in &mut columns {
|
|
if unique_columns.contains(&col.name) {
|
|
col.unique = true;
|
|
}
|
|
}
|
|
|
|
// Foreign keys from pragma_foreign_key_list.
|
|
let mut fk_stmt = conn
|
|
.prepare(
|
|
"SELECT \"table\", \"from\", \"to\", on_delete, on_update \
|
|
FROM pragma_foreign_key_list(?1) \
|
|
ORDER BY id, seq;",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let fk_rows = fk_stmt
|
|
.query_map([table], |row| {
|
|
let on_delete_str: String = row.get(3)?;
|
|
let on_update_str: String = row.get(4)?;
|
|
Ok(ReadForeignKey {
|
|
parent_table: row.get(0)?,
|
|
child_column: row.get(1)?,
|
|
parent_column: row.get(2)?,
|
|
on_delete: parse_action_from_sqlite(&on_delete_str),
|
|
on_update: parse_action_from_sqlite(&on_update_str),
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut foreign_keys = Vec::new();
|
|
for row in fk_rows {
|
|
foreign_keys.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
|
|
Ok(ReadSchema {
|
|
columns,
|
|
primary_key,
|
|
foreign_keys,
|
|
})
|
|
}
|
|
|
|
/// Read the user-created indexes on `table` (ADR-0025).
|
|
///
|
|
/// `pragma_index_list` reports every index; we keep only those
|
|
/// with origin `c` (a `CREATE INDEX` statement) and skip partial
|
|
/// indexes — the playground never creates partial indexes, and
|
|
/// surfacing the automatic PK / UNIQUE indexes as user indexes
|
|
/// would be misleading. Results are ordered by index name for
|
|
/// stable rendering.
|
|
fn read_table_indexes(conn: &Connection, table: &str) -> Result<Vec<IndexInfo>, DbError> {
|
|
let mut list_stmt = conn
|
|
.prepare(
|
|
"SELECT name, \"unique\", origin, partial \
|
|
FROM pragma_index_list(?1) \
|
|
ORDER BY name;",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let metas = list_stmt
|
|
.query_map([table], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, i64>(1)? != 0,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, i64>(3)? != 0,
|
|
))
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut keep: Vec<(String, bool)> = Vec::new();
|
|
for meta in metas {
|
|
let (name, unique, origin, partial) = meta.map_err(DbError::from_rusqlite)?;
|
|
if origin == "c" && !partial {
|
|
keep.push((name, unique));
|
|
}
|
|
}
|
|
let mut out = Vec::with_capacity(keep.len());
|
|
for (name, unique) in keep {
|
|
let columns = read_index_columns(conn, &name)?;
|
|
out.push(IndexInfo {
|
|
name,
|
|
columns,
|
|
unique,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The indexed columns of `index`, in index order.
|
|
fn read_index_columns(conn: &Connection, index: &str) -> Result<Vec<String>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT name FROM pragma_index_info(?1) ORDER BY seqno;",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([index], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn parse_action_from_sqlite(s: &str) -> ReferentialAction {
|
|
// SQLite stores the action keywords in upper-case form
|
|
// ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT").
|
|
s.parse::<ReferentialAction>()
|
|
.unwrap_or(ReferentialAction::NoAction)
|
|
}
|
|
|
|
/// Read the set of column names carrying a single-column UNIQUE
|
|
/// constraint on `table`, via `pragma_index_list` +
|
|
/// `pragma_index_info`. Filters to indexes whose `origin` is
|
|
/// `"u"` (a UNIQUE constraint, not a PK-implied or CHECK
|
|
/// auto-index) and which cover exactly one column. Compound
|
|
/// UNIQUE is deferred to a future ADR (out of scope for ADR-0018).
|
|
fn read_unique_columns(
|
|
conn: &Connection,
|
|
table: &str,
|
|
) -> Result<std::collections::HashSet<String>, DbError> {
|
|
let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
let mut idx_stmt = conn
|
|
.prepare(
|
|
"SELECT name, \"unique\", origin \
|
|
FROM pragma_index_list(?1);",
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let idx_rows = idx_stmt
|
|
.query_map([table], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, i64>(1)? != 0,
|
|
row.get::<_, String>(2)?,
|
|
))
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let indexes: Vec<(String, bool, String)> = idx_rows
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
for (idx_name, is_unique, origin) in indexes {
|
|
if !is_unique || origin != "u" {
|
|
continue;
|
|
}
|
|
let mut info_stmt = conn
|
|
.prepare("SELECT name FROM pragma_index_info(?1);")
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let cols: Vec<String> = info_stmt
|
|
.query_map([&idx_name], |row| row.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if cols.len() == 1 {
|
|
out.insert(cols.into_iter().next().expect("len 1"));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during
|
|
/// the rebuild dance.
|
|
fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
|
|
let mut clauses: Vec<String> = Vec::new();
|
|
|
|
// The single-column-INTEGER-PK case must be inline (`PRIMARY
|
|
// KEY` on the column itself) so SQLite gives it rowid-alias
|
|
// semantics. Compound or non-INTEGER PKs go to a table-level
|
|
// constraint.
|
|
let single_inline_pk = schema.primary_key.len() == 1
|
|
&& !schema.columns.is_empty()
|
|
&& schema.columns[0].primary_key
|
|
&& schema.primary_key[0] == schema.columns[0].name;
|
|
|
|
for col in &schema.columns {
|
|
let mut clause = format!(
|
|
"{ident} {sqlite_type}",
|
|
ident = quote_ident(&col.name),
|
|
sqlite_type = col.sqlite_type,
|
|
);
|
|
if col.notnull {
|
|
clause.push_str(" NOT NULL");
|
|
}
|
|
if single_inline_pk && col.primary_key {
|
|
clause.push_str(" PRIMARY KEY");
|
|
}
|
|
// Inline UNIQUE for columns flagged unique (ADR-0018 §4,
|
|
// ADR-0029 §9). A *single-column* PK is already UNIQUE
|
|
// via PRIMARY KEY, so suppress a redundant index there;
|
|
// a *compound*-PK member is not individually unique, so
|
|
// an explicit UNIQUE on it is a real, distinct rule.
|
|
let single_column_pk = schema.primary_key.len() == 1;
|
|
if col.unique && !(col.primary_key && single_column_pk) {
|
|
clause.push_str(" UNIQUE");
|
|
}
|
|
// ADR-0029 DEFAULT — echoed verbatim from the value
|
|
// SQLite reported, so the rebuild dance preserves it.
|
|
if let Some(default_sql) = &col.default_sql {
|
|
clause.push_str(" DEFAULT ");
|
|
clause.push_str(default_sql);
|
|
}
|
|
// ADR-0029 CHECK — echoed verbatim from the compiled
|
|
// SQL stored in the `check_expr` metadata column.
|
|
if let Some(check) = &col.check {
|
|
clause.push_str(" CHECK (");
|
|
clause.push_str(check);
|
|
clause.push(')');
|
|
}
|
|
clauses.push(clause);
|
|
}
|
|
|
|
if !single_inline_pk && !schema.primary_key.is_empty() {
|
|
let pk_idents: Vec<String> = schema.primary_key.iter().map(|n| quote_ident(n)).collect();
|
|
clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", ")));
|
|
}
|
|
|
|
for fk in &schema.foreign_keys {
|
|
clauses.push(format!(
|
|
"FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \
|
|
ON DELETE {od} ON UPDATE {ou}",
|
|
child = quote_ident(&fk.child_column),
|
|
parent_table = quote_ident(&fk.parent_table),
|
|
parent_col = quote_ident(&fk.parent_column),
|
|
od = fk.on_delete.sql_clause(),
|
|
ou = fk.on_update.sql_clause(),
|
|
));
|
|
}
|
|
|
|
format!(
|
|
"CREATE TABLE {ident} ({clauses}) STRICT;",
|
|
ident = quote_ident(table),
|
|
clauses = clauses.join(", "),
|
|
)
|
|
}
|
|
|
|
/// The rebuild-table dance. Replaces `table` with a fresh table
|
|
/// constructed from `new_schema`, then runs a caller-provided
|
|
/// `copy_data` step to populate it from the old table. The
|
|
/// `metadata_updates` closure runs inside the same transaction
|
|
/// after the data copy.
|
|
///
|
|
/// Most callers want the default INSERT…SELECT copy — they
|
|
/// should use [`rebuild_table`] which wraps this with that copy
|
|
/// strategy. The custom-copy form is reserved for `change column
|
|
/// type` (ADR-0017), which transforms one column's values
|
|
/// row-by-row before binding them.
|
|
///
|
|
/// Per the official SQLite ALTER-via-rebuild recipe: foreign-key
|
|
/// enforcement must be temporarily disabled at session level,
|
|
/// not just deferred, because the connection-level pragma is a
|
|
/// no-op inside transactions.
|
|
fn rebuild_table_with_copy<C, M>(
|
|
conn: &Connection,
|
|
table: &str,
|
|
new_schema: &ReadSchema,
|
|
copy_data: C,
|
|
metadata_updates: M,
|
|
) -> Result<(), DbError>
|
|
where
|
|
C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>,
|
|
M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
|
|
{
|
|
// foreign_keys=OFF must be set *outside* a transaction.
|
|
conn.execute_batch("PRAGMA foreign_keys = OFF;")
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
let result = (|| -> Result<(), DbError> {
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
let temp_name = format!("__rdbms_rebuild_{table}");
|
|
// Defensive: drop any leftover rebuild table from a
|
|
// previous failed attempt.
|
|
tx.execute_batch(&format!(
|
|
"DROP TABLE IF EXISTS {ident};",
|
|
ident = quote_ident(&temp_name)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
let create_temp = schema_to_ddl(&temp_name, new_schema);
|
|
tx.execute_batch(&create_temp)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
copy_data(&tx, &temp_name, table)?;
|
|
|
|
// Capture the table's user indexes before the drop —
|
|
// `DROP TABLE` discards them (ADR-0025). They are
|
|
// recreated verbatim after the rename: every caller of
|
|
// this primitive preserves the column set, so the index
|
|
// column references stay valid.
|
|
let captured_indexes = read_table_indexes(&tx, table)?;
|
|
|
|
tx.execute_batch(&format!(
|
|
"DROP TABLE {ident};",
|
|
ident = quote_ident(table)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute_batch(&format!(
|
|
"ALTER TABLE {temp} RENAME TO {final_name};",
|
|
temp = quote_ident(&temp_name),
|
|
final_name = quote_ident(table),
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
for index in &captured_indexes {
|
|
let cols = index
|
|
.columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let unique_kw = if index.unique { "UNIQUE " } else { "" };
|
|
tx.execute_batch(&format!(
|
|
"CREATE {unique_kw}INDEX {idx} ON {tbl} ({cols});",
|
|
idx = quote_ident(&index.name),
|
|
tbl = quote_ident(table),
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
|
|
metadata_updates(&tx)?;
|
|
|
|
// Verify referential integrity before committing. Any
|
|
// returned rows mean a FK violation persisted.
|
|
let mut check = tx
|
|
.prepare("PRAGMA foreign_key_check;")
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
|
|
if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? {
|
|
return Err(DbError::Sqlite {
|
|
message: format!(
|
|
"foreign-key check failed after rebuild of `{table}`; \
|
|
existing data violates the new constraint"
|
|
),
|
|
kind: SqliteErrorKind::Other,
|
|
});
|
|
}
|
|
drop(rows);
|
|
drop(check);
|
|
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(())
|
|
})();
|
|
|
|
// Always re-enable foreign_keys, even on error.
|
|
let pragma_result = conn
|
|
.execute_batch("PRAGMA foreign_keys = ON;")
|
|
.map_err(DbError::from_rusqlite);
|
|
|
|
result.and(pragma_result)
|
|
}
|
|
|
|
/// The default rebuild-table strategy: column-by-name copy via
|
|
/// `INSERT INTO new (cols) SELECT cols FROM old`, where `cols`
|
|
/// is the intersection of old and new column names.
|
|
fn rebuild_table<F>(
|
|
conn: &Connection,
|
|
table: &str,
|
|
old_schema: &ReadSchema,
|
|
new_schema: &ReadSchema,
|
|
metadata_updates: F,
|
|
) -> Result<(), DbError>
|
|
where
|
|
F: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
|
|
{
|
|
let copy_cols: Vec<String> = new_schema
|
|
.columns
|
|
.iter()
|
|
.filter(|c| old_schema.columns.iter().any(|oc| oc.name == c.name))
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
|
|
rebuild_table_with_copy(
|
|
conn,
|
|
table,
|
|
new_schema,
|
|
|tx, temp_name, orig| {
|
|
if copy_cols.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let cols_csv = copy_cols
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let copy_sql = format!(
|
|
"INSERT INTO {temp} ({cols}) SELECT {cols} FROM {orig};",
|
|
temp = quote_ident(temp_name),
|
|
cols = cols_csv,
|
|
orig = quote_ident(orig),
|
|
);
|
|
tx.execute_batch(©_sql).map_err(DbError::from_rusqlite)
|
|
},
|
|
metadata_updates,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn do_add_relationship(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
name: Option<&str>,
|
|
parent_table: &str,
|
|
parent_column: &str,
|
|
child_table: &str,
|
|
child_column: &str,
|
|
on_delete: ReferentialAction,
|
|
on_update: ReferentialAction,
|
|
create_fk: bool,
|
|
) -> Result<TableDescription, DbError> {
|
|
// 1. Read parent schema; verify the referenced column is a PK.
|
|
let parent_schema = read_schema(conn, parent_table)?;
|
|
let parent_col = parent_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == parent_column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {parent_table}.{parent_column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
if !parent_col.primary_key {
|
|
return Err(DbError::Unsupported(format!(
|
|
"column `{parent_table}.{parent_column}` is not a primary key. \
|
|
Foreign keys must reference a primary key (UNIQUE-target FKs \
|
|
land in a later iteration)."
|
|
)));
|
|
}
|
|
|
|
// 2. Read child schema; verify the FK column or auto-create.
|
|
let mut child_schema = read_schema(conn, child_table)?;
|
|
let needs_create_column = child_schema
|
|
.columns
|
|
.iter()
|
|
.all(|c| c.name != child_column);
|
|
if needs_create_column && !create_fk {
|
|
return Err(DbError::Unsupported(format!(
|
|
"column `{child_table}.{child_column}` does not exist. \
|
|
Add it first, or use `--create-fk` to create it automatically."
|
|
)));
|
|
}
|
|
|
|
// 3. Determine child column type. Either the existing one's
|
|
// user_type, or the parent's fk_target_type for auto-create.
|
|
let parent_user_type = parent_col.user_type.ok_or_else(|| DbError::Unsupported(
|
|
"parent column has no user type metadata".to_string(),
|
|
))?;
|
|
let expected_child_type = parent_user_type.fk_target_type();
|
|
|
|
if needs_create_column {
|
|
// Synthesise the column row for the new schema.
|
|
child_schema.columns.push(ReadColumn {
|
|
name: child_column.to_string(),
|
|
sqlite_type: expected_child_type.sqlite_strict_type().to_string(),
|
|
notnull: false,
|
|
primary_key: false,
|
|
unique: false,
|
|
default_sql: None,
|
|
check: None,
|
|
user_type: Some(expected_child_type),
|
|
});
|
|
} else {
|
|
// Validate type compatibility against the existing column.
|
|
let child_col = child_schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == child_column)
|
|
.expect("checked above");
|
|
let actual = child_col.user_type.ok_or_else(|| DbError::Unsupported(
|
|
"child column has no user type metadata".to_string(),
|
|
))?;
|
|
if actual != expected_child_type {
|
|
return Err(DbError::Unsupported(format!(
|
|
"type mismatch: `{child_table}.{child_column}` is `{actual}` but \
|
|
a foreign key referencing `{parent_table}.{parent_column}` \
|
|
(`{parent_user_type}`) requires `{expected_child_type}`. \
|
|
Either change the column type, or pick a different FK column."
|
|
)));
|
|
}
|
|
}
|
|
|
|
// 4. Determine relationship name (auto-gen or supplied) and
|
|
// check uniqueness against the metadata table.
|
|
let resolved_name = name.map_or_else(
|
|
// Auto-name follows the user-typed `from <Parent>.<col>
|
|
// to <Child>.<col>` direction so the name reads as the
|
|
// grammar reads — see ADR-0013.
|
|
|| format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"),
|
|
ToString::to_string,
|
|
);
|
|
let collision: i64 = conn
|
|
.query_row(
|
|
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
|
|
[&resolved_name],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if collision > 0 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"a relationship named `{resolved_name}` already exists. \
|
|
Pick a different name or drop the existing one first."
|
|
)));
|
|
}
|
|
|
|
// 5. Build the new schema with the FK appended.
|
|
let mut new_schema = child_schema.clone();
|
|
new_schema.foreign_keys.push(ReadForeignKey {
|
|
parent_table: parent_table.to_string(),
|
|
parent_column: parent_column.to_string(),
|
|
child_column: child_column.to_string(),
|
|
on_delete,
|
|
on_update,
|
|
});
|
|
|
|
// 6. Rebuild, with metadata updates inside the transaction.
|
|
let on_delete_kw = on_delete.keyword();
|
|
let on_update_kw = on_update.keyword();
|
|
let column_user_type_kw = expected_child_type.keyword();
|
|
let resolved_name_for_meta = resolved_name.as_str();
|
|
rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| {
|
|
if needs_create_column {
|
|
tx.execute(
|
|
&format!(
|
|
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
|
VALUES (?1, ?2, ?3);"
|
|
),
|
|
[child_table, child_column, column_user_type_kw],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
tx.execute(
|
|
&format!(
|
|
"INSERT INTO {REL_TABLE} \
|
|
(name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
|
|
),
|
|
[
|
|
resolved_name_for_meta,
|
|
parent_table,
|
|
parent_column,
|
|
child_table,
|
|
child_column,
|
|
on_delete_kw,
|
|
on_update_kw,
|
|
],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
// Persistence runs inside the same tx so a write
|
|
// failure rolls back both the schema and the metadata
|
|
// (commit-db-last per ADR-0015 §6).
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
// The child table was rebuilt — its CSV needs to
|
|
// be re-emitted from the new state too, in case
|
|
// the FK column was newly created.
|
|
rewritten_tables: vec![child_table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
})?;
|
|
|
|
// Return the parent (1-side) description so the user sees
|
|
// the new relationship via the inbound section — that's the
|
|
// perspective that matches the `from <Parent>.col to ...`
|
|
// direction of the command.
|
|
do_describe_table(conn, parent_table)
|
|
}
|
|
|
|
fn do_drop_relationship(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
selector: &RelationshipSelector,
|
|
) -> Result<Option<TableDescription>, DbError> {
|
|
// Resolve to a single relationship row.
|
|
let resolved: Option<(String, String, String, String, String)> = match selector {
|
|
RelationshipSelector::Named { name } => conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT name, parent_table, parent_column, child_table, child_column \
|
|
FROM {REL_TABLE} WHERE name = ?1;"
|
|
),
|
|
[name],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
|
)
|
|
.ok(),
|
|
RelationshipSelector::Endpoints {
|
|
parent_table,
|
|
parent_column,
|
|
child_table,
|
|
child_column,
|
|
} => conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT name, parent_table, parent_column, child_table, child_column \
|
|
FROM {REL_TABLE} \
|
|
WHERE parent_table = ?1 AND parent_column = ?2 \
|
|
AND child_table = ?3 AND child_column = ?4;"
|
|
),
|
|
[parent_table, parent_column, child_table, child_column],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
|
)
|
|
.ok(),
|
|
};
|
|
let (rel_name, parent_table, _parent_column, child_table, child_column) =
|
|
resolved.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such relationship: {selector}"),
|
|
kind: SqliteErrorKind::Other,
|
|
})?;
|
|
|
|
// Read child schema; build new schema without the FK.
|
|
let old_schema = read_schema(conn, &child_table)?;
|
|
let mut new_schema = old_schema.clone();
|
|
new_schema.foreign_keys.retain(|fk| fk.child_column != child_column);
|
|
|
|
let child_table_for_persist = child_table.clone();
|
|
rebuild_table(conn, &child_table, &old_schema, &new_schema, |tx| {
|
|
tx.execute(
|
|
&format!("DELETE FROM {REL_TABLE} WHERE name = ?1;"),
|
|
[rel_name.as_str()],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
rewritten_tables: vec![child_table_for_persist.clone()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(tx, persistence, source, &changes)?;
|
|
Ok(())
|
|
})?;
|
|
|
|
// Show the parent (1-side) afterwards — same direction as
|
|
// the command's `from <Parent> to ...` reading.
|
|
Ok(Some(do_describe_table(conn, &parent_table)?))
|
|
}
|
|
|
|
/// Create an index on `table` over `columns` (ADR-0025).
|
|
///
|
|
/// Refuses a redundant index on an already-indexed column set
|
|
/// and a name collision. The index name is auto-generated as
|
|
/// `<table>_<col…>_idx` when not supplied.
|
|
fn do_add_index(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
name: Option<&str>,
|
|
table: &str,
|
|
columns: &[String],
|
|
) -> Result<TableDescription, DbError> {
|
|
// 1. Table must exist; gather its columns.
|
|
let schema = read_schema(conn, table)?;
|
|
// 2. Every indexed column must exist on the table.
|
|
for col in columns {
|
|
if !schema.columns.iter().any(|c| &c.name == col) {
|
|
return Err(DbError::Sqlite {
|
|
message: format!("no such column: {table}.{col}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
});
|
|
}
|
|
}
|
|
// 3. Refuse a redundant index over an identical column set.
|
|
let existing = read_table_indexes(conn, table)?;
|
|
if let Some(dup) = existing
|
|
.iter()
|
|
.find(|i| i.columns.as_slice() == columns)
|
|
{
|
|
return Err(DbError::Unsupported(format!(
|
|
"the columns ({}) of `{table}` are already indexed by `{}`.",
|
|
columns.join(", "),
|
|
dup.name,
|
|
)));
|
|
}
|
|
// 4. Resolve the index name (auto-generate when omitted).
|
|
let resolved = name.map_or_else(
|
|
|| format!("{table}_{}_idx", columns.join("_")),
|
|
ToString::to_string,
|
|
);
|
|
// 5. Refuse a name collision.
|
|
let name_taken: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master \
|
|
WHERE type = 'index' AND name = ?1;",
|
|
[&resolved],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
if name_taken > 0 {
|
|
return Err(DbError::Unsupported(format!(
|
|
"an index named `{resolved}` already exists. \
|
|
Pick a different name or drop the existing one first."
|
|
)));
|
|
}
|
|
// 6. Create the index and persist.
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let cols_csv = columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let ddl = format!(
|
|
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
|
idx = quote_ident(&resolved),
|
|
tbl = quote_ident(table),
|
|
cols = cols_csv,
|
|
);
|
|
debug!(ddl = %ddl, "add_index");
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
let description = do_describe_table(conn, table)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(description)
|
|
}
|
|
|
|
/// Drop an index identified by name or by table + column set
|
|
/// (ADR-0025). Returns the affected table's description.
|
|
fn do_drop_index(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
selector: &IndexSelector,
|
|
) -> Result<TableDescription, DbError> {
|
|
let (index_name, table_name) = match selector {
|
|
IndexSelector::Named { name } => {
|
|
let lookup = conn.query_row(
|
|
"SELECT tbl_name FROM sqlite_master \
|
|
WHERE type = 'index' AND name = ?1 AND sql IS NOT NULL;",
|
|
[name],
|
|
|row| row.get::<_, String>(0),
|
|
);
|
|
match lookup {
|
|
Ok(table) => (name.clone(), table),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return Err(DbError::Sqlite {
|
|
message: format!("no such index: {name}"),
|
|
kind: SqliteErrorKind::Other,
|
|
});
|
|
}
|
|
Err(e) => return Err(DbError::from_rusqlite(e)),
|
|
}
|
|
}
|
|
IndexSelector::Columns { table, columns } => {
|
|
// Surface a missing table as such, not as "no index".
|
|
read_schema(conn, table)?;
|
|
let matches: Vec<IndexInfo> = read_table_indexes(conn, table)?
|
|
.into_iter()
|
|
.filter(|i| i.columns.as_slice() == columns.as_slice())
|
|
.collect();
|
|
match matches.as_slice() {
|
|
[] => {
|
|
return Err(DbError::Sqlite {
|
|
message: format!(
|
|
"no index on {table} ({}) exists",
|
|
columns.join(", ")
|
|
),
|
|
kind: SqliteErrorKind::Other,
|
|
});
|
|
}
|
|
[one] => (one.name.clone(), table.clone()),
|
|
many => {
|
|
let names = many
|
|
.iter()
|
|
.map(|i| format!("`{}`", i.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return Err(DbError::Unsupported(format!(
|
|
"more than one index on {table} ({}) matches \
|
|
({names}); drop it by name instead.",
|
|
columns.join(", ")
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
tx.execute_batch(&format!(
|
|
"DROP INDEX {ident};",
|
|
ident = quote_ident(&index_name)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let description = do_describe_table(conn, &table_name)?;
|
|
let changes = Changes {
|
|
schema_dirty: true,
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(description)
|
|
}
|
|
|
|
/// Read-only wrapper around `do_describe_table` that runs an
|
|
/// auxiliary `history.log` append for user-issued
|
|
/// `show table` commands.
|
|
fn do_describe_table_request(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
name: &str,
|
|
) -> Result<TableDescription, DbError> {
|
|
let description = do_describe_table(conn, name)?;
|
|
if let (Some(p), Some(text)) = (persistence, source) {
|
|
p.append_history(text)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
Ok(description)
|
|
}
|
|
|
|
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
|
// Column info — including the ADR-0029 constraints — comes
|
|
// from `read_schema`, the single source of per-column truth
|
|
// (it joins `pragma_table_info` with our type metadata and
|
|
// detects single-column UNIQUE via `pragma_index_list`). A
|
|
// missing table surfaces as `read_schema`'s NoSuchTable.
|
|
let schema = read_schema(conn, name)?;
|
|
let columns: Vec<ColumnDescription> = schema
|
|
.columns
|
|
.iter()
|
|
.map(|c| ColumnDescription {
|
|
name: c.name.clone(),
|
|
user_type: c.user_type,
|
|
sqlite_type: c.sqlite_type.clone(),
|
|
notnull: c.notnull,
|
|
primary_key: c.primary_key,
|
|
unique: c.unique,
|
|
default: c.default_sql.clone(),
|
|
check: c.check.clone(),
|
|
})
|
|
.collect();
|
|
|
|
let outbound_relationships = read_relationships_outbound(conn, name)?;
|
|
let inbound_relationships = read_relationships_inbound(conn, name)?;
|
|
let indexes = read_table_indexes(conn, name)?;
|
|
|
|
Ok(TableDescription {
|
|
name: name.to_string(),
|
|
columns,
|
|
outbound_relationships,
|
|
inbound_relationships,
|
|
indexes,
|
|
})
|
|
}
|
|
|
|
// --- Data operations (C5) -----------------------------------
|
|
|
|
fn impl_value_for(
|
|
schema: &ReadSchema,
|
|
column: &str,
|
|
value: &Value,
|
|
) -> Result<Bound, DbError> {
|
|
let col = schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == column)
|
|
.ok_or_else(|| DbError::Sqlite {
|
|
message: format!("no such column: {column}"),
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
})?;
|
|
let ty = col.user_type.ok_or_else(|| {
|
|
DbError::Unsupported(format!(
|
|
"column `{column}` has no user-type metadata; cannot validate value"
|
|
))
|
|
})?;
|
|
value
|
|
.bind_for_column(column, ty)
|
|
.map_err(|e: ValueError| DbError::InvalidValue(e.to_string()))
|
|
}
|
|
|
|
fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value {
|
|
use rusqlite::types::Value as V;
|
|
match b {
|
|
Bound::Integer(i) => V::Integer(*i),
|
|
Bound::Real(r) => V::Real(*r),
|
|
Bound::Text(s) => V::Text(s.clone()),
|
|
Bound::Null => V::Null,
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// WHERE-expression → parameterised SQL (ADR-0026 §6)
|
|
// =================================================================
|
|
|
|
/// Compile an `Expr` to a parameterised SQL boolean expression.
|
|
///
|
|
/// Every literal becomes a `?` placeholder pushed onto `params`
|
|
/// (1-based, continuing from the current `params.len()` — so
|
|
/// the caller can pre-load SET-clause params); identifiers are
|
|
/// `quote_ident`-quoted. The raw user text is never spliced
|
|
/// into the SQL. Connectives, `NOT`, and parentheses come from
|
|
/// the tree structure; the database re-derives precedence from
|
|
/// the emitted operators.
|
|
fn compile_expr(
|
|
expr: &Expr,
|
|
schema: &ReadSchema,
|
|
params: &mut Vec<rusqlite::types::Value>,
|
|
) -> String {
|
|
match expr {
|
|
Expr::Or(terms) => join_expr(terms, "OR", schema, params),
|
|
Expr::And(terms) => join_expr(terms, "AND", schema, params),
|
|
Expr::Not(inner) => {
|
|
format!("(NOT {})", compile_expr(inner, schema, params))
|
|
}
|
|
Expr::Predicate(predicate) => compile_predicate(predicate, schema, params),
|
|
}
|
|
}
|
|
|
|
fn join_expr(
|
|
terms: &[Expr],
|
|
op: &str,
|
|
schema: &ReadSchema,
|
|
params: &mut Vec<rusqlite::types::Value>,
|
|
) -> String {
|
|
let parts: Vec<String> = terms
|
|
.iter()
|
|
.map(|t| compile_expr(t, schema, params))
|
|
.collect();
|
|
format!("({})", parts.join(&format!(" {op} ")))
|
|
}
|
|
|
|
fn compile_predicate(
|
|
predicate: &Predicate,
|
|
schema: &ReadSchema,
|
|
params: &mut Vec<rusqlite::types::Value>,
|
|
) -> String {
|
|
match predicate {
|
|
Predicate::Compare { left, op, right } => {
|
|
// A literal on one side binds against the column on
|
|
// the other side, where there is one.
|
|
let left_ty = operand_column_type(left, schema);
|
|
let right_ty = operand_column_type(right, schema);
|
|
let lhs = compile_operand(left, right_ty, params);
|
|
let rhs = compile_operand(right, left_ty, params);
|
|
format!("{lhs} {} {rhs}", compare_op_sql(*op))
|
|
}
|
|
Predicate::Like {
|
|
target,
|
|
pattern,
|
|
negated,
|
|
} => {
|
|
let t = compile_operand(target, None, params);
|
|
let p = compile_operand(pattern, None, params);
|
|
let not = if *negated { "NOT " } else { "" };
|
|
format!("{t} {not}LIKE {p}")
|
|
}
|
|
Predicate::Between {
|
|
target,
|
|
low,
|
|
high,
|
|
negated,
|
|
} => {
|
|
let ty = operand_column_type(target, schema);
|
|
let t = compile_operand(target, None, params);
|
|
let lo = compile_operand(low, ty, params);
|
|
let hi = compile_operand(high, ty, params);
|
|
let not = if *negated { "NOT " } else { "" };
|
|
format!("{t} {not}BETWEEN {lo} AND {hi}")
|
|
}
|
|
Predicate::In {
|
|
target,
|
|
items,
|
|
negated,
|
|
} => {
|
|
let ty = operand_column_type(target, schema);
|
|
let t = compile_operand(target, None, params);
|
|
let rendered: Vec<String> = items
|
|
.iter()
|
|
.map(|item| compile_operand(item, ty, params))
|
|
.collect();
|
|
let not = if *negated { "NOT " } else { "" };
|
|
format!("{t} {not}IN ({})", rendered.join(", "))
|
|
}
|
|
Predicate::IsNull { target, negated } => {
|
|
let t = compile_operand(target, None, params);
|
|
let not = if *negated { "NOT " } else { "" };
|
|
format!("{t} IS {not}NULL")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render an operand. A column becomes a quoted identifier; a
|
|
/// literal becomes a `?` placeholder bound against `against`'s
|
|
/// type when one is supplied.
|
|
fn compile_operand(
|
|
operand: &Operand,
|
|
against: Option<Type>,
|
|
params: &mut Vec<rusqlite::types::Value>,
|
|
) -> String {
|
|
match operand {
|
|
Operand::Column { name, .. } => quote_ident(name),
|
|
Operand::Literal { value, .. } => {
|
|
params.push(bind_where_literal(value, against));
|
|
format!("?{}", params.len())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The user-facing type of a column operand, if the operand is
|
|
/// a column the schema knows. Literals and unknown columns
|
|
/// yield `None`.
|
|
fn operand_column_type(operand: &Operand, schema: &ReadSchema) -> Option<Type> {
|
|
match operand {
|
|
Operand::Column { name, .. } => schema
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name.eq_ignore_ascii_case(name))
|
|
.and_then(|c| c.user_type),
|
|
Operand::Literal { .. } => None,
|
|
}
|
|
}
|
|
|
|
/// Bind a WHERE-clause literal. When a target column type is
|
|
/// known and the literal converts cleanly, bind through it;
|
|
/// otherwise bind by the literal's own syntactic shape — a
|
|
/// type-mismatched comparison is flagged in the editor but
|
|
/// still runs (ADR-0026 §7).
|
|
fn bind_where_literal(value: &Value, against: Option<Type>) -> rusqlite::types::Value {
|
|
if let Some(ty) = against
|
|
&& let Ok(bound) = value.bind_for_column("", ty)
|
|
{
|
|
return bound_to_sqlite_value(&bound);
|
|
}
|
|
bound_to_sqlite_value(&syntactic_bound(value))
|
|
}
|
|
|
|
/// Bind a literal by the shape it was written in, ignoring any
|
|
/// column type — the permissive fallback for type-mismatched
|
|
/// comparisons and the literal-vs-literal case.
|
|
fn syntactic_bound(value: &Value) -> Bound {
|
|
match value {
|
|
Value::Null => Bound::Null,
|
|
Value::Bool(b) => Bound::Integer(i64::from(*b)),
|
|
Value::Text(s) => Bound::Text(s.clone()),
|
|
Value::Number(s) => s.parse::<i64>().map_or_else(
|
|
|_| {
|
|
s.parse::<f64>()
|
|
.map_or_else(|_| Bound::Text(s.clone()), Bound::Real)
|
|
},
|
|
Bound::Integer,
|
|
),
|
|
}
|
|
}
|
|
|
|
const fn compare_op_sql(op: CompareOp) -> &'static str {
|
|
match op {
|
|
CompareOp::Eq => "=",
|
|
// `<>` is standard SQL for inequality (ADR-0026 §6).
|
|
CompareOp::NotEq => "<>",
|
|
CompareOp::Lt => "<",
|
|
CompareOp::LtEq => "<=",
|
|
CompareOp::Gt => ">",
|
|
CompareOp::GtEq => ">=",
|
|
}
|
|
}
|
|
|
|
/// Execute an INSERT/UPDATE/DELETE and convert any rusqlite
|
|
/// failure into a `DbError`. Wraps the raw `conn.execute` so the
|
|
/// three callers (insert, update, delete) have a single hook for
|
|
/// future row-pinpointing per ADR-0019 §6 — when re-query lands,
|
|
/// the runtime-side wiring will pass the table identity into the
|
|
/// translator from here.
|
|
///
|
|
/// Today this is a thin wrapper. The `table` argument is
|
|
/// retained as the future re-query hook needs it. (The previous
|
|
/// `enrich_fk_message` helper that lived here, listing all the
|
|
/// outbound/inbound FKs in the error message, was absorbed into
|
|
/// the friendly-error layer's catalog wording per ADR-0019;
|
|
/// re-introducing the per-FK detail belongs to the re-query
|
|
/// follow-on, not as freeform plain-text appended to engine
|
|
/// errors.)
|
|
fn execute_with_fk_enrichment(
|
|
conn: &Connection,
|
|
_table: &str,
|
|
sql: &str,
|
|
params: &[rusqlite::types::Value],
|
|
) -> Result<usize, DbError> {
|
|
conn.execute(sql, rusqlite::params_from_iter(params.iter()))
|
|
.map_err(DbError::from_rusqlite)
|
|
}
|
|
|
|
/// Fetch a small `DataResult` containing only the rows whose
|
|
/// rowids appear in `rowids`. Used so writes can show only the
|
|
/// rows they touched rather than the whole table.
|
|
fn query_rows_by_rowid(
|
|
conn: &Connection,
|
|
table: &str,
|
|
rowids: &[i64],
|
|
) -> Result<DataResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
let column_types: Vec<Option<Type>> =
|
|
schema.columns.iter().map(|c| c.user_type).collect();
|
|
|
|
if rowids.is_empty() {
|
|
return Ok(DataResult {
|
|
table_name: table.to_string(),
|
|
columns: column_names,
|
|
column_types,
|
|
rows: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let cols_csv = column_names
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let placeholders = (1..=rowids.len())
|
|
.map(|i| format!("?{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sql = format!(
|
|
"SELECT {cols} FROM {ident} WHERE rowid IN ({placeholders});",
|
|
cols = cols_csv,
|
|
ident = quote_ident(table),
|
|
);
|
|
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
let params: Vec<rusqlite::types::Value> = rowids
|
|
.iter()
|
|
.map(|id| rusqlite::types::Value::Integer(*id))
|
|
.collect();
|
|
let rows_iter = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
|
let mut cells: Vec<rusqlite::types::Value> =
|
|
Vec::with_capacity(column_names.len());
|
|
for i in 0..column_names.len() {
|
|
cells.push(row.get(i)?);
|
|
}
|
|
Ok(cells)
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
|
for r in rows_iter {
|
|
let cells = r.map_err(DbError::from_rusqlite)?;
|
|
let formatted: Vec<Option<String>> = cells
|
|
.into_iter()
|
|
.zip(column_types.iter())
|
|
.map(|(v, ty)| format_cell(v, *ty))
|
|
.collect();
|
|
rows.push(formatted);
|
|
}
|
|
Ok(DataResult {
|
|
table_name: table.to_string(),
|
|
columns: column_names,
|
|
column_types,
|
|
rows,
|
|
})
|
|
}
|
|
|
|
fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
|
|
let sql = format!("SELECT COUNT(*) FROM {ident};", ident = quote_ident(table));
|
|
conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
|
|
.map_err(DbError::from_rusqlite)
|
|
}
|
|
|
|
fn do_insert(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
user_columns: Option<&[String]>,
|
|
user_values: &[Value],
|
|
) -> Result<InsertResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
|
|
// Resolve which columns the user is providing values for.
|
|
let user_cols: Vec<String> = match user_columns {
|
|
Some(cols) => cols.to_vec(),
|
|
None => {
|
|
// Short form: every non-auto-generated column in
|
|
// schema declaration order. Serial and shortid both
|
|
// get auto-filled below.
|
|
schema
|
|
.columns
|
|
.iter()
|
|
.filter(|c| !matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId)))
|
|
.map(|c| c.name.clone())
|
|
.collect()
|
|
}
|
|
};
|
|
|
|
if user_cols.len() != user_values.len() {
|
|
return Err(DbError::InvalidValue(format!(
|
|
"expected {} value(s) for ({}), got {}",
|
|
user_cols.len(),
|
|
user_cols.join(", "),
|
|
user_values.len()
|
|
)));
|
|
}
|
|
|
|
let mut bindings: Vec<(String, Bound)> = Vec::with_capacity(user_cols.len());
|
|
for (col_name, value) in user_cols.iter().zip(user_values.iter()) {
|
|
let bound = impl_value_for(&schema, col_name, value)?;
|
|
bindings.push((col_name.clone(), bound));
|
|
}
|
|
|
|
// Auto-fill any shortid columns the user didn't list.
|
|
let provided: std::collections::HashSet<String> =
|
|
bindings.iter().map(|(c, _)| c.clone()).collect();
|
|
for c in &schema.columns {
|
|
if c.user_type == Some(Type::ShortId) && !provided.contains(&c.name) {
|
|
bindings.push((c.name.clone(), Bound::Text(shortid::generate())));
|
|
}
|
|
}
|
|
|
|
// Auto-fill any non-PK serial columns the user didn't list
|
|
// (ADR-0018 §5). PK serial columns rely on SQLite's rowid
|
|
// alias (omitting the column from the INSERT yields the
|
|
// next rowid for free); non-PK serial needs explicit
|
|
// MAX(col)+1 application-side because there's no engine-
|
|
// level auto-increment for non-PK columns. The worker-
|
|
// thread serialisation (ADR-0010) makes this read-then-
|
|
// write sequence safe without explicit locking.
|
|
for c in &schema.columns {
|
|
if c.user_type == Some(Type::Serial)
|
|
&& !c.primary_key
|
|
&& !provided.contains(&c.name)
|
|
{
|
|
let next: i64 = conn
|
|
.query_row(
|
|
&format!(
|
|
"SELECT COALESCE(MAX({col}), 0) + 1 FROM {tbl};",
|
|
col = quote_ident(&c.name),
|
|
tbl = quote_ident(table),
|
|
),
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
bindings.push((c.name.clone(), Bound::Integer(next)));
|
|
}
|
|
}
|
|
|
|
if bindings.is_empty() {
|
|
return Err(DbError::InvalidValue(
|
|
"INSERT requires at least one column value".to_string(),
|
|
));
|
|
}
|
|
|
|
let cols_csv = bindings
|
|
.iter()
|
|
.map(|(c, _)| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let placeholders = (1..=bindings.len())
|
|
.map(|i| format!("?{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sql = format!(
|
|
"INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
|
|
ident = quote_ident(table),
|
|
);
|
|
debug!(sql = %sql, "insert");
|
|
let params: Vec<rusqlite::types::Value> =
|
|
bindings.iter().map(|(_, b)| bound_to_sqlite_value(b)).collect();
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?;
|
|
let new_rowid = conn.last_insert_rowid();
|
|
let data = query_rows_by_rowid(conn, table, &[new_rowid])?;
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(InsertResult {
|
|
rows_affected,
|
|
data,
|
|
})
|
|
}
|
|
|
|
/// Build the parameterised `UPDATE … SET … WHERE …` statement.
|
|
/// Separated from `do_update` so the `explain` path runs
|
|
/// `EXPLAIN QUERY PLAN` against the exact same statement
|
|
/// (ADR-0028 §2). `compile_expr` continues the `?N` numbering
|
|
/// from the SET-clause params already pushed.
|
|
fn build_update_sql(
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
assignments: &[(String, Value)],
|
|
filter: &RowFilter,
|
|
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
|
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
|
|
for (col, value) in assignments {
|
|
let bound = impl_value_for(schema, col, value)?;
|
|
set_clauses.push(format!(
|
|
"{col_id} = ?{n}",
|
|
col_id = quote_ident(col),
|
|
n = params.len() + 1
|
|
));
|
|
params.push(bound_to_sqlite_value(&bound));
|
|
}
|
|
let where_sql = match filter {
|
|
RowFilter::AllRows => String::new(),
|
|
RowFilter::Where(expr) => {
|
|
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
|
}
|
|
};
|
|
let sql = format!(
|
|
"UPDATE {ident} SET {sets}{where_sql};",
|
|
ident = quote_ident(table),
|
|
sets = set_clauses.join(", "),
|
|
);
|
|
Ok((sql, params))
|
|
}
|
|
|
|
fn do_update(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
assignments: &[(String, Value)],
|
|
filter: &RowFilter,
|
|
) -> Result<UpdateResult, DbError> {
|
|
if assignments.is_empty() {
|
|
return Err(DbError::InvalidValue(
|
|
"UPDATE requires at least one assignment".to_string(),
|
|
));
|
|
}
|
|
let schema = read_schema(conn, table)?;
|
|
|
|
// Capture rowids of matching rows up front so we can fetch
|
|
// the updated rows even if the UPDATE changed the WHERE column.
|
|
let rowids = match filter {
|
|
RowFilter::AllRows => select_all_rowids(conn, table)?,
|
|
RowFilter::Where(expr) => {
|
|
let mut where_params: Vec<rusqlite::types::Value> = Vec::new();
|
|
let clause = compile_expr(expr, &schema, &mut where_params);
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT rowid FROM {ident} WHERE {clause};",
|
|
ident = quote_ident(table),
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map(rusqlite::params_from_iter(where_params.iter()), |row| {
|
|
row.get::<_, i64>(0)
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut ids = Vec::new();
|
|
for r in rows {
|
|
ids.push(r.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
ids
|
|
}
|
|
};
|
|
|
|
let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
|
|
debug!(sql = %sql, "update");
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?;
|
|
let data = query_rows_by_rowid(conn, table, &rowids)?;
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables: vec![table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(UpdateResult {
|
|
rows_affected,
|
|
data,
|
|
})
|
|
}
|
|
|
|
fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT rowid FROM {ident};",
|
|
ident = quote_ident(table)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |row| row.get::<_, i64>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut ids = Vec::new();
|
|
for r in rows {
|
|
ids.push(r.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(ids)
|
|
}
|
|
|
|
/// Build the parameterised `DELETE FROM … WHERE …` statement.
|
|
/// Separated from `do_delete` so the `explain` path runs
|
|
/// `EXPLAIN QUERY PLAN` against the exact same statement
|
|
/// (ADR-0028 §2).
|
|
fn build_delete_sql(
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
filter: &RowFilter,
|
|
) -> (String, Vec<rusqlite::types::Value>) {
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
|
let where_sql = match filter {
|
|
RowFilter::AllRows => String::new(),
|
|
RowFilter::Where(expr) => {
|
|
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
|
}
|
|
};
|
|
let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table));
|
|
(sql, params)
|
|
}
|
|
|
|
fn do_delete(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
filter: &RowFilter,
|
|
) -> Result<DeleteResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
|
|
// Snapshot child-table row counts before the delete so we
|
|
// can detect cascade effects via diffing afterwards. ON
|
|
// UPDATE CASCADE does not change row counts and so is not
|
|
// detected here — it would need value-level diffing, which
|
|
// a future iteration can add.
|
|
let inbound = read_relationships_inbound(conn, table)?;
|
|
let mut before_counts: Vec<(String, i64)> = Vec::with_capacity(inbound.len());
|
|
for r in &inbound {
|
|
before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
|
|
}
|
|
|
|
let (sql, params) = build_delete_sql(&schema, table, filter);
|
|
debug!(sql = %sql, "delete");
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?;
|
|
|
|
// Compare child-table counts after the delete; non-zero
|
|
// diffs are cascade effects. We also collect cascaded
|
|
// tables so the persistence phase rewrites their CSVs too.
|
|
let mut cascade: Vec<CascadeEffect> = Vec::new();
|
|
let mut rewritten_tables: Vec<String> = vec![table.to_string()];
|
|
for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) {
|
|
let after_count = count_rows(conn, &rel.other_table)?;
|
|
let mut rows_changed = before_count - after_count;
|
|
// A self-referential FK (child == target): the before/after
|
|
// diff also covers the directly-deleted rows, which are
|
|
// already reported in `rows_affected` and are not cascade
|
|
// effects. Subtract them so the summary reports only the rows
|
|
// removed *via* the self-reference. Shared with `do_sql_delete`.
|
|
let self_referential = rel.other_table == table;
|
|
if self_referential {
|
|
rows_changed -= rows_affected as i64;
|
|
}
|
|
if rows_changed > 0 {
|
|
cascade.push(CascadeEffect {
|
|
relationship_name: rel.name.clone(),
|
|
child_table: rel.other_table.clone(),
|
|
rows_changed,
|
|
action: rel.on_delete,
|
|
});
|
|
// The target's CSV is already queued; only add a distinct
|
|
// child table (a self-ref child is the target itself).
|
|
if !self_referential {
|
|
rewritten_tables.push(rel.other_table.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables,
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
|
|
Ok(DeleteResult {
|
|
rows_affected,
|
|
cascade,
|
|
})
|
|
}
|
|
|
|
/// Read-only wrapper that adds the `history.log` append for
|
|
/// `show data` user commands.
|
|
fn do_query_data_request(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
table: &str,
|
|
filter: Option<&Expr>,
|
|
limit: Option<u64>,
|
|
) -> Result<DataResult, DbError> {
|
|
let data = do_query_data(conn, table, filter, limit)?;
|
|
if let (Some(p), Some(text)) = (persistence, source) {
|
|
p.append_history(text)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
Ok(data)
|
|
}
|
|
|
|
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
|
|
/// ADR-0031). Mirrors `do_query_data_request`: run the
|
|
/// statement, append the literal line to `history.log` so a
|
|
/// replay re-runs it (ADR-0030 §11).
|
|
fn do_run_select_request(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
sql: &str,
|
|
) -> Result<DataResult, DbError> {
|
|
let data = do_run_select(conn, sql)?;
|
|
if let (Some(p), Some(text)) = (persistence, source) {
|
|
p.append_history(text)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
Ok(data)
|
|
}
|
|
|
|
/// Currently-stored non-NULL values of one column, for shortid
|
|
/// collision-avoidance (passed to `generate_shortid_batch`).
|
|
fn existing_shortids(
|
|
conn: &Connection,
|
|
table: &str,
|
|
column: &str,
|
|
) -> Result<Vec<String>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;",
|
|
col = quote_ident(column),
|
|
tbl = quote_ident(table),
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([], |r| r.get::<_, String>(0))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for r in rows {
|
|
out.push(r.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Plan `shortid` auto-fill for a SQL `INSERT` (ADR-0033 §6,
|
|
/// sub-phase 3d).
|
|
///
|
|
/// Returns the SQL the worker should execute plus its bound
|
|
/// params. When the user's `(column_list)` omits one or more
|
|
/// `shortid` columns, this materialises the row source (Option B:
|
|
/// run it as a query), synthesises a fresh distinct id per row via
|
|
/// `generate_shortid_batch`, and reconstructs a parameterised
|
|
/// multi-row `INSERT` over the listed columns plus the omitted
|
|
/// shortid columns. Otherwise it returns the original `sql`
|
|
/// verbatim with no params (the 3b path):
|
|
///
|
|
/// - no explicit column list → the row source supplies every
|
|
/// column positionally (a listed shortid is the user's value);
|
|
/// - no omitted shortid column → nothing to fill;
|
|
/// - the row source yields zero rows → nothing to fill (the
|
|
/// verbatim INSERT inserts nothing without a NOT-NULL violation).
|
|
///
|
|
/// `serial` columns are not handled here — an omitted `serial`
|
|
/// primary key is filled by the engine's rowid (ADR-0033 §6).
|
|
fn plan_shortid_autofill(
|
|
conn: &Connection,
|
|
target_table: &str,
|
|
sql: &str,
|
|
listed_columns: &[String],
|
|
row_source: &str,
|
|
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
|
|
if listed_columns.is_empty() {
|
|
return Ok((sql.to_string(), Vec::new()));
|
|
}
|
|
let schema = read_schema(conn, target_table)?;
|
|
// Identifiers are case-preserving but matched case-insensitively
|
|
// (ADR-0009): a shortid column counts as omitted unless the user
|
|
// listed a name equal to it ignoring ASCII case.
|
|
let listed_ci: Vec<String> =
|
|
listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect();
|
|
let omitted_shortids: Vec<String> = schema
|
|
.columns
|
|
.iter()
|
|
.filter(|c| c.user_type == Some(Type::ShortId))
|
|
.filter(|c| !listed_ci.contains(&c.name.to_ascii_lowercase()))
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
if omitted_shortids.is_empty() {
|
|
return Ok((sql.to_string(), Vec::new()));
|
|
}
|
|
|
|
// Materialise the row source (VALUES / SELECT / WITH … SELECT)
|
|
// as concrete rows for the listed columns.
|
|
let listed_count = listed_columns.len();
|
|
let mut stmt = conn.prepare(row_source).map_err(DbError::from_rusqlite)?;
|
|
// Arity guard: if the row source's column count disagrees with
|
|
// the user's column list, do NOT auto-fill — reading
|
|
// `listed_count` cells would silently drop extra columns (or
|
|
// error opaquely on too few). Defer to the verbatim statement
|
|
// so the engine reports the mismatch as it does on the
|
|
// non-auto-fill path (a friendly pre-flight lands in 3i).
|
|
if stmt.column_count() != listed_count {
|
|
return Ok((sql.to_string(), Vec::new()));
|
|
}
|
|
let mut rows: Vec<Vec<rusqlite::types::Value>> = Vec::new();
|
|
{
|
|
let mut q = stmt.query([]).map_err(DbError::from_rusqlite)?;
|
|
while let Some(r) = q.next().map_err(DbError::from_rusqlite)? {
|
|
let mut cells = Vec::with_capacity(listed_count);
|
|
for i in 0..listed_count {
|
|
cells.push(
|
|
r.get::<_, rusqlite::types::Value>(i)
|
|
.map_err(DbError::from_rusqlite)?,
|
|
);
|
|
}
|
|
rows.push(cells);
|
|
}
|
|
}
|
|
let n = rows.len();
|
|
if n == 0 {
|
|
// Nothing to insert — the verbatim statement inserts zero
|
|
// rows without touching the omitted shortid column.
|
|
return Ok((sql.to_string(), Vec::new()));
|
|
}
|
|
|
|
// A fresh, distinct shortid per row for each omitted column,
|
|
// avoiding collision with values already stored in that column.
|
|
let mut id_batches: Vec<Vec<rusqlite::types::Value>> =
|
|
Vec::with_capacity(omitted_shortids.len());
|
|
for col in &omitted_shortids {
|
|
let existing = existing_shortids(conn, target_table, col)?;
|
|
id_batches.push(generate_shortid_batch(n, &existing)?);
|
|
}
|
|
|
|
// Reconstruct: listed columns followed by the omitted shortid
|
|
// columns; one parameterised tuple per materialised row.
|
|
let all_cols: Vec<&String> =
|
|
listed_columns.iter().chain(omitted_shortids.iter()).collect();
|
|
let cols_csv = all_cols
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let per_tuple = all_cols.len();
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(n * per_tuple);
|
|
let mut tuples: Vec<String> = Vec::with_capacity(n);
|
|
let mut ph = 1;
|
|
for (row_idx, row) in rows.into_iter().enumerate() {
|
|
for cell in row {
|
|
params.push(cell);
|
|
}
|
|
for batch in &id_batches {
|
|
params.push(batch[row_idx].clone());
|
|
}
|
|
let placeholders = (0..per_tuple)
|
|
.map(|_| {
|
|
let s = format!("?{ph}");
|
|
ph += 1;
|
|
s
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
tuples.push(format!("({placeholders})"));
|
|
}
|
|
let exec_sql = format!(
|
|
"INSERT INTO {tbl} ({cols_csv}) VALUES {vals};",
|
|
tbl = quote_ident(target_table),
|
|
vals = tuples.join(", "),
|
|
);
|
|
Ok((exec_sql, params))
|
|
}
|
|
|
|
/// Worker handler for `Request::RunSqlInsert` (ADR-0033 §1,
|
|
/// sub-phase 3b). Mirrors `do_insert`'s persistence discipline:
|
|
/// run the validated SQL inside a transaction, re-persist the
|
|
/// target table's CSV + append `history.log` via
|
|
/// `finalize_persistence` *before* `tx.commit()` (so a
|
|
/// persistence failure rolls the insert back), then commit.
|
|
///
|
|
/// Grammar-as-text (ADR-0030 §4): normally the values are literals
|
|
/// in `sql` and no parameters are bound. The sub-phase 3d shortid
|
|
/// auto-fill path is the exception — it reconstructs a
|
|
/// parameterised `INSERT` (see `plan_shortid_autofill`); either
|
|
/// way `history.log` records the original `source`, never the
|
|
/// rewritten statement (ADR-0030 §11). FK / UNIQUE / NOT NULL
|
|
/// engine errors surface enriched via `execute_with_fk_enrichment`
|
|
/// + the friendly-error layer.
|
|
///
|
|
/// Auto-show is best-effort: the inserted rows are the last
|
|
/// `rows_affected` rowids ending at `last_insert_rowid()`. For the
|
|
/// common case (sequential / engine-assigned rowids) this is
|
|
/// exactly the inserted rows; an INSERT that sets explicit
|
|
/// non-contiguous rowid/INTEGER-PK values may surface a partial
|
|
/// view. `RETURNING` (sub-phase 3g) is the precise tool.
|
|
fn do_sql_insert(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
sql: &str,
|
|
target_table: &str,
|
|
listed_columns: &[String],
|
|
row_source: &str,
|
|
) -> Result<InsertResult, DbError> {
|
|
debug!(sql = %sql, table = %target_table, "sql_insert");
|
|
// Sub-phase 3d: when the user's column list omits one or more
|
|
// `shortid` columns, the worker materialises the row source,
|
|
// synthesises fresh distinct ids, and reinserts the augmented
|
|
// rows. Returns the executable SQL + bound params; an empty
|
|
// params vec with the original `sql` means "no auto-fill —
|
|
// execute verbatim" (the 3b path).
|
|
let (exec_sql, params) =
|
|
plan_shortid_autofill(conn, target_table, sql, listed_columns, row_source)?;
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected =
|
|
execute_with_fk_enrichment(conn, target_table, &exec_sql, ¶ms)?;
|
|
let last = conn.last_insert_rowid();
|
|
let rowids: Vec<i64> = if rows_affected == 0 {
|
|
Vec::new()
|
|
} else {
|
|
let n = rows_affected as i64;
|
|
((last - n + 1)..=last).collect()
|
|
};
|
|
let data = query_rows_by_rowid(conn, target_table, &rowids)?;
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables: vec![target_table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(InsertResult {
|
|
rows_affected,
|
|
data,
|
|
})
|
|
}
|
|
|
|
/// Worker handler for `Request::RunSqlUpdate` (ADR-0033 §2,
|
|
/// sub-phase 3e). Mirrors `do_sql_insert`'s persistence
|
|
/// discipline: run the validated SQL inside a transaction,
|
|
/// re-persist the target table's CSV + append `history.log` via
|
|
/// `finalize_persistence` *before* `tx.commit()`, then commit.
|
|
///
|
|
/// Grammar-as-text (ADR-0030 §4): the assignment and predicate
|
|
/// values are literals in `sql`, so no parameters are bound. A SQL
|
|
/// `UPDATE` without `WHERE` runs across all rows as written
|
|
/// (ADR-0030 §12 — no `--all-rows` rail). An update matching zero
|
|
/// rows is a success (`rows_affected == 0`); the persistence
|
|
/// write-through still runs (re-persisting the unchanged CSV is a
|
|
/// no-op-equivalent and keeps the path uniform).
|
|
///
|
|
/// Auto-show: 3e returns an empty [`DataResult`] — the affected
|
|
/// rows can't be shown precisely without `RETURNING` (sub-phase
|
|
/// 3g, which is the precise tool). The summary surfaces the
|
|
/// affected-row count; the renderer skips the (column-less) table.
|
|
fn do_sql_update(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
sql: &str,
|
|
target_table: &str,
|
|
) -> Result<UpdateResult, DbError> {
|
|
debug!(sql = %sql, table = %target_table, "sql_update");
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables: vec![target_table.to_string()],
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(UpdateResult {
|
|
rows_affected,
|
|
data: DataResult {
|
|
table_name: target_table.to_string(),
|
|
columns: Vec::new(),
|
|
column_types: Vec::new(),
|
|
rows: Vec::new(),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7,
|
|
/// sub-phase 3f). Mirrors the DSL `do_delete` exactly, differing
|
|
/// only in that it executes the verbatim grammar-validated `sql`
|
|
/// rather than building the statement from a typed filter.
|
|
///
|
|
/// Cascade detection (ADR-0033 Amendment 2): the worker snapshots
|
|
/// each inbound child table's row count *before* the DELETE, runs
|
|
/// the statement inside a transaction (the engine applies any
|
|
/// `ON DELETE CASCADE`), counts the children again *after*, and
|
|
/// reports the positive difference as a [`CascadeEffect`]. This is
|
|
/// the identical mechanism the DSL path uses, so the SQL and DSL
|
|
/// DELETE produce the same per-relationship summary on the same
|
|
/// schema/data — and since both return a [`DeleteResult`] routed
|
|
/// through `CommandOutcome::Delete`, the render-layer formatter is
|
|
/// shared with no duplication. `ON DELETE SET NULL` leaves row
|
|
/// counts unchanged and so is not reported on either path (a
|
|
/// deferred enhancement for both).
|
|
///
|
|
/// Because the diff observes the result of executing the whole
|
|
/// statement, the WHERE clause is never inspected — a WHERE that
|
|
/// itself contains a subquery (the R2 invariant) is correct by
|
|
/// construction and carries no extra per-child query cost.
|
|
///
|
|
/// Persistence discipline matches `do_delete` / `do_sql_update`:
|
|
/// re-persist the target's CSV *and every cascade-affected child's*
|
|
/// CSV via `finalize_persistence` (which also appends `source` to
|
|
/// `history.log`) *before* `tx.commit()`, so a persistence failure
|
|
/// rolls the delete back. A DELETE matching zero rows is a success
|
|
/// (`rows_affected == 0`, empty cascade); the target's CSV is still
|
|
/// re-persisted, keeping the path uniform.
|
|
fn do_sql_delete(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
sql: &str,
|
|
target_table: &str,
|
|
) -> Result<DeleteResult, DbError> {
|
|
debug!(sql = %sql, table = %target_table, "sql_delete");
|
|
|
|
// Snapshot child-table row counts before the delete so cascade
|
|
// effects can be detected by diffing afterwards (Amendment 2;
|
|
// identical to `do_delete`). ON UPDATE CASCADE / ON DELETE SET
|
|
// NULL do not change row counts and so are not detected here.
|
|
let inbound = read_relationships_inbound(conn, target_table)?;
|
|
let mut before_counts: Vec<i64> = Vec::with_capacity(inbound.len());
|
|
for r in &inbound {
|
|
before_counts.push(count_rows(conn, &r.other_table)?);
|
|
}
|
|
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows_affected = execute_with_fk_enrichment(conn, target_table, sql, &[])?;
|
|
|
|
// Compare child-table counts after the delete; positive diffs
|
|
// are cascade effects. Collect the cascaded tables so the
|
|
// persistence phase rewrites their CSVs too.
|
|
let mut cascade: Vec<CascadeEffect> = Vec::new();
|
|
let mut rewritten_tables: Vec<String> = vec![target_table.to_string()];
|
|
for (rel, before_count) in inbound.iter().zip(before_counts.iter()) {
|
|
let after_count = count_rows(conn, &rel.other_table)?;
|
|
let mut rows_changed = before_count - after_count;
|
|
// A self-referential FK (child == target): the before/after
|
|
// diff also covers the directly-deleted rows, which are
|
|
// already reported in `rows_affected` and are not cascade
|
|
// effects. Subtract them so the summary reports only the rows
|
|
// removed *via* the self-reference.
|
|
let self_referential = rel.other_table == target_table;
|
|
if self_referential {
|
|
rows_changed -= rows_affected as i64;
|
|
}
|
|
if rows_changed > 0 {
|
|
cascade.push(CascadeEffect {
|
|
relationship_name: rel.name.clone(),
|
|
child_table: rel.other_table.clone(),
|
|
rows_changed,
|
|
action: rel.on_delete,
|
|
});
|
|
// The target's CSV is already queued; only add a distinct
|
|
// child table (a self-ref child is the target itself).
|
|
if !self_referential {
|
|
rewritten_tables.push(rel.other_table.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
let changes = Changes {
|
|
schema_dirty: false,
|
|
rewritten_tables,
|
|
..Changes::default()
|
|
};
|
|
finalize_persistence(conn, persistence, source, &changes)?;
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
|
|
Ok(DeleteResult {
|
|
rows_affected,
|
|
cascade,
|
|
})
|
|
}
|
|
|
|
/// Execute a grammar-validated SQL `SELECT` and collect its
|
|
/// rows into a [`DataResult`] (ADR-0030 §6, ADR-0032 §12 +
|
|
/// Amendment 1).
|
|
///
|
|
/// Per-column playground types are recovered from the engine's
|
|
/// column-origin metadata (`column_table_name` /
|
|
/// `column_origin_name`, surfaced by rusqlite's
|
|
/// `columns_with_metadata`). The Amendment-1 empirical probe
|
|
/// confirmed the metadata follows through non-recursive CTEs,
|
|
/// scalar subqueries, derived tables, set ops, and JOINs; only
|
|
/// computed projections and recursive-CTE result columns return
|
|
/// `None`. The renderer (ADR-0016) handles typed columns
|
|
/// (bool → true/false, etc.) and falls back to neutral
|
|
/// alignment for `None`.
|
|
fn do_run_select(conn: &Connection, sql: &str) -> Result<DataResult, DbError> {
|
|
debug!(sql = %sql, "run_select");
|
|
let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?;
|
|
let column_names: Vec<String> = stmt
|
|
.column_names()
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
let col_count = column_names.len();
|
|
let column_types = resolve_select_column_types(conn, &stmt);
|
|
let rows_iter = stmt
|
|
.query_map([], |row| {
|
|
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(col_count);
|
|
for i in 0..col_count {
|
|
cells.push(row.get(i)?);
|
|
}
|
|
Ok(cells)
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
|
for r in rows_iter {
|
|
let cells = r.map_err(DbError::from_rusqlite)?;
|
|
rows.push(
|
|
cells
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, v)| format_cell(v, column_types.get(i).copied().flatten()))
|
|
.collect(),
|
|
);
|
|
}
|
|
Ok(DataResult {
|
|
table_name: String::new(),
|
|
columns: column_names,
|
|
column_types,
|
|
rows,
|
|
})
|
|
}
|
|
|
|
/// Resolve playground types for each result column of a
|
|
/// prepared SELECT statement (ADR-0032 §12 + Amendment 1).
|
|
///
|
|
/// For each result column, query the engine's column-origin
|
|
/// metadata. If both `table_name` and `origin_name` are
|
|
/// populated (the result column traces back to a base-table
|
|
/// column), look up the playground type in
|
|
/// `__rdbms_playground_columns`. Otherwise the slot stays
|
|
/// `None` — Amendment 1 documents that recursive-CTE result
|
|
/// columns and computed projections are the only structural
|
|
/// classes that don't follow through.
|
|
fn resolve_select_column_types(
|
|
conn: &Connection,
|
|
stmt: &rusqlite::Statement,
|
|
) -> Vec<Option<Type>> {
|
|
let metas = stmt.columns_with_metadata();
|
|
if metas.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
// Prepare the lookup once; reuse across columns.
|
|
let mut lookup = match conn.prepare(&format!(
|
|
"SELECT user_type FROM {META_TABLE} \
|
|
WHERE table_name = ?1 COLLATE NOCASE \
|
|
AND column_name = ?2 COLLATE NOCASE"
|
|
)) {
|
|
Ok(s) => s,
|
|
Err(_) => return vec![None; metas.len()],
|
|
};
|
|
metas
|
|
.iter()
|
|
.map(|m| {
|
|
let table = m.table_name()?;
|
|
let origin = m.origin_name()?;
|
|
lookup
|
|
.query_row(rusqlite::params![table, origin], |row| {
|
|
row.get::<_, String>(0)
|
|
})
|
|
.ok()
|
|
.and_then(|kw| kw.parse::<Type>().ok())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the parameterised `SELECT … FROM …` statement for a
|
|
/// `show data` query (ADR-0026 §5–§6). Separated from
|
|
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
|
|
/// PLAN` against the *exact* same statement (ADR-0028 §2).
|
|
///
|
|
/// A `limit` implies a stable primary-key `ORDER BY` so
|
|
/// `limit n` is "first n by primary key" rather than an
|
|
/// arbitrary subset.
|
|
fn build_query_data_sql(
|
|
schema: &ReadSchema,
|
|
table: &str,
|
|
filter: Option<&Expr>,
|
|
limit: Option<u64>,
|
|
) -> (String, Vec<rusqlite::types::Value>) {
|
|
let cols_csv = schema
|
|
.columns
|
|
.iter()
|
|
.map(|c| quote_ident(&c.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
|
let where_sql = filter.map_or_else(String::new, |expr| {
|
|
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
|
});
|
|
let (order_sql, limit_sql) = limit.map_or_else(
|
|
|| (String::new(), String::new()),
|
|
|n| {
|
|
let order = if schema.primary_key.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let pk = schema
|
|
.primary_key
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
format!(" ORDER BY {pk}")
|
|
};
|
|
params.push(rusqlite::types::Value::Integer(
|
|
i64::try_from(n).unwrap_or(i64::MAX),
|
|
));
|
|
(order, format!(" LIMIT ?{}", params.len()))
|
|
},
|
|
);
|
|
let sql = format!(
|
|
"SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
|
|
ident = quote_ident(table),
|
|
);
|
|
(sql, params)
|
|
}
|
|
|
|
fn do_query_data(
|
|
conn: &Connection,
|
|
table: &str,
|
|
filter: Option<&Expr>,
|
|
limit: Option<u64>,
|
|
) -> Result<DataResult, DbError> {
|
|
let schema = read_schema(conn, table)?;
|
|
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
|
let column_types: Vec<Option<Type>> =
|
|
schema.columns.iter().map(|c| c.user_type).collect();
|
|
|
|
let (sql, params) = build_query_data_sql(&schema, table, filter, limit);
|
|
debug!(sql = %sql, "query_data");
|
|
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
let rows_iter = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
|
let mut cells: Vec<rusqlite::types::Value> = Vec::with_capacity(column_names.len());
|
|
for i in 0..column_names.len() {
|
|
let v: rusqlite::types::Value = row.get(i)?;
|
|
cells.push(v);
|
|
}
|
|
Ok(cells)
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
|
for r in rows_iter {
|
|
let cells = r.map_err(DbError::from_rusqlite)?;
|
|
let formatted: Vec<Option<String>> = cells
|
|
.into_iter()
|
|
.zip(column_types.iter())
|
|
.map(|(v, ty)| format_cell(v, *ty))
|
|
.collect();
|
|
rows.push(formatted);
|
|
}
|
|
Ok(DataResult {
|
|
table_name: table.to_string(),
|
|
columns: column_names,
|
|
column_types,
|
|
rows,
|
|
})
|
|
}
|
|
|
|
fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String> {
|
|
use rusqlite::types::Value as V;
|
|
match value {
|
|
V::Null => None,
|
|
V::Integer(i) => Some(if matches!(ty, Some(Type::Bool)) {
|
|
(if i == 0 { "false" } else { "true" }).to_string()
|
|
} else {
|
|
i.to_string()
|
|
}),
|
|
V::Real(r) => Some(format!("{r}")),
|
|
V::Text(s) => Some(s),
|
|
V::Blob(b) => Some(format!("<blob {} bytes>", b.len())),
|
|
}
|
|
}
|
|
|
|
/// Capture the query plan for an explainable command
|
|
/// (ADR-0028 §2). Matches the inner command, builds the exact
|
|
/// SQL it would otherwise run via the shared `build_*_sql`
|
|
/// helpers, runs `EXPLAIN QUERY PLAN` against it (which never
|
|
/// executes the statement), and pairs the plan rows with a
|
|
/// standard-SQL display form of the statement.
|
|
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
|
|
let (exec_sql, params) = match query {
|
|
Command::ShowData {
|
|
name,
|
|
filter,
|
|
limit,
|
|
} => {
|
|
let schema = read_schema(conn, name)?;
|
|
build_query_data_sql(&schema, name, filter.as_ref(), *limit)
|
|
}
|
|
Command::Update {
|
|
table,
|
|
assignments,
|
|
filter,
|
|
} => {
|
|
let schema = read_schema(conn, table)?;
|
|
build_update_sql(&schema, table, assignments, filter)?
|
|
}
|
|
Command::Delete { table, filter } => {
|
|
let schema = read_schema(conn, table)?;
|
|
build_delete_sql(&schema, table, filter)
|
|
}
|
|
other => {
|
|
// The grammar only ever wraps the three explainable
|
|
// commands; a different inner command means a
|
|
// synthesised `Command::Explain` (tests, scripting).
|
|
return Err(DbError::Unsupported(format!(
|
|
"cannot explain `{}`",
|
|
other.verb()
|
|
)));
|
|
}
|
|
};
|
|
let rows = run_explain_query_plan(conn, &exec_sql, ¶ms)?;
|
|
let display_sql = inline_params_for_display(&exec_sql, ¶ms);
|
|
debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
|
|
Ok(QueryPlan { display_sql, rows })
|
|
}
|
|
|
|
/// Prepare `EXPLAIN QUERY PLAN <sql>` and read back its tree
|
|
/// rows. The inner statement's parameters are bound so the
|
|
/// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines
|
|
/// the plan from structure, not parameter values (ADR-0028 §2).
|
|
fn run_explain_query_plan(
|
|
conn: &Connection,
|
|
sql: &str,
|
|
params: &[rusqlite::types::Value],
|
|
) -> Result<Vec<ExplainRow>, DbError> {
|
|
let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
|
|
let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?;
|
|
// `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)`
|
|
// — the `notused` column at index 2 is dropped.
|
|
let rows_iter = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
|
Ok(ExplainRow {
|
|
id: row.get(0)?,
|
|
parent: row.get(1)?,
|
|
detail: row.get(3)?,
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for r in rows_iter {
|
|
out.push(r.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Render execution SQL as human-facing display SQL by inlining
|
|
/// each `?N` placeholder as a standard-SQL literal (ADR-0028
|
|
/// §3). The scan is quote-aware — a `?N`-shaped run inside a
|
|
/// double-quoted identifier is left untouched — and the
|
|
/// trailing `;` is dropped so the result reads as a query.
|
|
fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String {
|
|
let mut out = String::with_capacity(sql.len());
|
|
let mut chars = sql.chars().peekable();
|
|
let mut in_quote = false;
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'"' => {
|
|
in_quote = !in_quote;
|
|
out.push('"');
|
|
}
|
|
'?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => {
|
|
let mut digits = String::new();
|
|
while let Some(d) = chars.peek() {
|
|
if d.is_ascii_digit() {
|
|
digits.push(*d);
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
match digits
|
|
.parse::<usize>()
|
|
.ok()
|
|
.and_then(|n| n.checked_sub(1))
|
|
.and_then(|idx| params.get(idx))
|
|
{
|
|
Some(value) => out.push_str(&sql_literal(value)),
|
|
// Out of range — leave the placeholder verbatim.
|
|
None => {
|
|
out.push('?');
|
|
out.push_str(&digits);
|
|
}
|
|
}
|
|
}
|
|
other => out.push(other),
|
|
}
|
|
}
|
|
out.trim_end().trim_end_matches(';').trim_end().to_string()
|
|
}
|
|
|
|
/// Render a bound parameter as a standard-SQL literal for the
|
|
/// display SQL (ADR-0028 §3). Text is single-quoted with
|
|
/// embedded quotes doubled; a real with no fractional part
|
|
/// keeps a `.0` so it still reads as a real.
|
|
fn sql_literal(value: &rusqlite::types::Value) -> String {
|
|
use rusqlite::types::Value as V;
|
|
match value {
|
|
V::Null => "NULL".to_string(),
|
|
V::Integer(i) => i.to_string(),
|
|
V::Real(r) => {
|
|
let s = r.to_string();
|
|
if s.contains(['.', 'e', 'E']) {
|
|
s
|
|
} else {
|
|
format!("{s}.0")
|
|
}
|
|
}
|
|
V::Text(s) => format!("'{}'", s.replace('\'', "''")),
|
|
V::Blob(bytes) => {
|
|
let mut hex = String::with_capacity(bytes.len() * 2 + 3);
|
|
hex.push_str("x'");
|
|
for b in bytes {
|
|
hex.push_str(&format!("{b:02x}"));
|
|
}
|
|
hex.push('\'');
|
|
hex
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_relationships_outbound(
|
|
conn: &Connection,
|
|
table: &str,
|
|
) -> Result<Vec<RelationshipEnd>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT name, parent_table, parent_column, child_column, on_delete, on_update \
|
|
FROM {REL_TABLE} \
|
|
WHERE child_table = ?1 \
|
|
ORDER BY name;"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([table], |row| {
|
|
let on_delete: String = row.get(4)?;
|
|
let on_update: String = row.get(5)?;
|
|
Ok(RelationshipEnd {
|
|
name: row.get(0)?,
|
|
other_table: row.get(1)?,
|
|
other_column: row.get(2)?,
|
|
local_column: row.get(3)?,
|
|
on_delete: on_delete
|
|
.parse::<ReferentialAction>()
|
|
.unwrap_or(ReferentialAction::NoAction),
|
|
on_update: on_update
|
|
.parse::<ReferentialAction>()
|
|
.unwrap_or(ReferentialAction::NoAction),
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn read_relationships_inbound(
|
|
conn: &Connection,
|
|
table: &str,
|
|
) -> Result<Vec<RelationshipEnd>, DbError> {
|
|
let mut stmt = conn
|
|
.prepare(&format!(
|
|
"SELECT name, child_table, child_column, parent_column, on_delete, on_update \
|
|
FROM {REL_TABLE} \
|
|
WHERE parent_table = ?1 \
|
|
ORDER BY name;"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let rows = stmt
|
|
.query_map([table], |row| {
|
|
let on_delete: String = row.get(4)?;
|
|
let on_update: String = row.get(5)?;
|
|
Ok(RelationshipEnd {
|
|
name: row.get(0)?,
|
|
other_table: row.get(1)?,
|
|
other_column: row.get(2)?,
|
|
local_column: row.get(3)?,
|
|
on_delete: on_delete
|
|
.parse::<ReferentialAction>()
|
|
.unwrap_or(ReferentialAction::NoAction),
|
|
on_update: on_update
|
|
.parse::<ReferentialAction>()
|
|
.unwrap_or(ReferentialAction::NoAction),
|
|
})
|
|
})
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
out.push(row.map_err(DbError::from_rusqlite)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Rebuild the database from `project.yaml` + `data/<table>.csv`
|
|
/// (ADR-0015 §7).
|
|
///
|
|
/// The on-disk text is the authoritative source: this function
|
|
/// recreates schema, metadata, and rows so the resulting `.db`
|
|
/// reflects them exactly. Persistence callbacks are NOT invoked
|
|
/// for the schema/data writes; we're loading, not changing
|
|
/// user-visible state. The exception is `history.log`: when
|
|
/// `source` is `Some`, the rebuild was user-initiated (via the
|
|
/// `rebuild` app-level command) and is appended as a successful
|
|
/// command per ADR-0015 §5.
|
|
///
|
|
/// Existing user tables and metadata rows are wiped at the
|
|
/// start of the rebuild so this function works on both fresh
|
|
/// and populated databases — the silent on-load case (empty
|
|
/// db) sees a no-op wipe; the explicit `rebuild` command
|
|
/// replaces whatever was there.
|
|
///
|
|
/// FK enforcement is disabled for the load and re-enabled at
|
|
/// the end (regardless of success). A `foreign_key_check`
|
|
/// before commit verifies the loaded data is consistent — any
|
|
/// violation aborts with a fatal error.
|
|
fn do_rebuild_from_text(
|
|
conn: &Connection,
|
|
persistence: Option<&Persistence>,
|
|
source: Option<&str>,
|
|
project_path: &Path,
|
|
) -> Result<(), DbError> {
|
|
let yaml_path = project_path.join(PROJECT_YAML);
|
|
let data_dir = project_path.join(DATA_DIR);
|
|
|
|
let yaml_body =
|
|
std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal {
|
|
operation: "read",
|
|
path: yaml_path.clone(),
|
|
message: e.to_string(),
|
|
})?;
|
|
let snapshot = parse_schema(&yaml_body).map_err(|e| DbError::PersistenceFatal {
|
|
operation: "parse",
|
|
path: yaml_path.clone(),
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
conn.execute_batch("PRAGMA foreign_keys = OFF;")
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
let result = (|| -> Result<(), DbError> {
|
|
let tx = conn
|
|
.unchecked_transaction()
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
// 0. Wipe any existing user tables + metadata so the
|
|
// rebuild can start from a clean slate. This step
|
|
// is a no-op on a freshly-created database (the
|
|
// silent rebuild on missing-`.db`); on the explicit
|
|
// `rebuild` command it replaces the live state with
|
|
// the text-source state per ADR-0015 §7.
|
|
let existing_tables = do_list_tables(&tx)?;
|
|
for name in &existing_tables {
|
|
tx.execute_batch(&format!(
|
|
"DROP TABLE {ident};",
|
|
ident = quote_ident(name)
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
tx.execute_batch(&format!(
|
|
"DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
// 1. Recreate user tables with FK constraints inline.
|
|
for table in &snapshot.tables {
|
|
let read_schema = build_read_schema(table, &snapshot.relationships);
|
|
let ddl = schema_to_ddl(&table.name, &read_schema);
|
|
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
|
}
|
|
|
|
// 2. Column-type metadata.
|
|
{
|
|
let mut stmt = tx
|
|
.prepare(&format!(
|
|
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
|
VALUES (?1, ?2, ?3);"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
for table in &snapshot.tables {
|
|
for col in &table.columns {
|
|
stmt.execute([
|
|
table.name.as_str(),
|
|
col.name.as_str(),
|
|
col.user_type.keyword(),
|
|
])
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Relationship metadata.
|
|
{
|
|
let mut stmt = tx
|
|
.prepare(&format!(
|
|
"INSERT INTO {REL_TABLE} \
|
|
(name, parent_table, parent_column, child_table, child_column, \
|
|
on_delete, on_update) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
for rel in &snapshot.relationships {
|
|
stmt.execute([
|
|
rel.name.as_str(),
|
|
rel.parent_table.as_str(),
|
|
rel.parent_column.as_str(),
|
|
rel.child_table.as_str(),
|
|
rel.child_column.as_str(),
|
|
rel.on_delete.keyword(),
|
|
rel.on_update.keyword(),
|
|
])
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
}
|
|
|
|
// 4. Project metadata: overwrite the configure-time
|
|
// `created_at` with the YAML's authoritative value.
|
|
tx.execute(
|
|
&format!(
|
|
"INSERT INTO {META_PROJECT_TABLE} (key, value) VALUES ('created_at', ?1) \
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
|
|
),
|
|
[snapshot.created_at.as_str()],
|
|
)
|
|
.map_err(DbError::from_rusqlite)?;
|
|
|
|
// 5. Load each table's rows (if a CSV is present).
|
|
for table in &snapshot.tables {
|
|
let csv_path = data_dir.join(format!("{}.csv", table.name));
|
|
if !csv_path.exists() {
|
|
continue;
|
|
}
|
|
load_table_csv(&tx, table, &csv_path)?;
|
|
}
|
|
|
|
// 5b. Recreate indexes (ADR-0025). Done after the data
|
|
// load — the result is identical either way, and
|
|
// this keeps the structural steps (tables, FKs,
|
|
// data) ahead of the derived index objects.
|
|
for index in &snapshot.indexes {
|
|
let cols = index
|
|
.columns
|
|
.iter()
|
|
.map(|c| quote_ident(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
tx.execute_batch(&format!(
|
|
"CREATE INDEX {idx} ON {tbl} ({cols});",
|
|
idx = quote_ident(&index.name),
|
|
tbl = quote_ident(&index.table),
|
|
))
|
|
.map_err(DbError::from_rusqlite)?;
|
|
}
|
|
|
|
// 6. Verify FK consistency before committing.
|
|
{
|
|
let mut check = tx
|
|
.prepare("PRAGMA foreign_key_check;")
|
|
.map_err(DbError::from_rusqlite)?;
|
|
let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
|
|
if rows.next().map_err(DbError::from_rusqlite)?.is_some() {
|
|
return Err(DbError::PersistenceFatal {
|
|
operation: "rebuild",
|
|
path: yaml_path.clone(),
|
|
message: "rebuilt data violates foreign-key constraints".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 7. Append `history.log` if this rebuild was
|
|
// user-initiated (the silent on-load case has
|
|
// `source = None`).
|
|
if let (Some(p), Some(text)) = (persistence, source) {
|
|
p.append_history(text)
|
|
.map_err(DbError::from_persistence)?;
|
|
}
|
|
|
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
|
Ok(())
|
|
})();
|
|
|
|
let pragma_result = conn
|
|
.execute_batch("PRAGMA foreign_keys = ON;")
|
|
.map_err(DbError::from_rusqlite);
|
|
result.and(pragma_result)
|
|
}
|
|
|
|
/// Build a `ReadSchema` for `table` that includes any
|
|
/// relationships from the snapshot in which `table` is the
|
|
/// child. The output drives `schema_to_ddl` so the resulting
|
|
/// CREATE TABLE has the FKs inline.
|
|
fn build_read_schema(table: &TableSchema, relationships: &[RelationshipSchema]) -> ReadSchema {
|
|
let columns: Vec<ReadColumn> = table
|
|
.columns
|
|
.iter()
|
|
.map(|c| ReadColumn {
|
|
name: c.name.clone(),
|
|
sqlite_type: c.user_type.sqlite_strict_type().to_string(),
|
|
notnull: c.not_null,
|
|
primary_key: table.primary_key.contains(&c.name),
|
|
unique: c.unique,
|
|
default_sql: c.default.clone(),
|
|
check: c.check.clone(),
|
|
user_type: Some(c.user_type),
|
|
})
|
|
.collect();
|
|
let foreign_keys: Vec<ReadForeignKey> = relationships
|
|
.iter()
|
|
.filter(|r| r.child_table == table.name)
|
|
.map(|r| ReadForeignKey {
|
|
parent_table: r.parent_table.clone(),
|
|
parent_column: r.parent_column.clone(),
|
|
child_column: r.child_column.clone(),
|
|
on_delete: r.on_delete,
|
|
on_update: r.on_update,
|
|
})
|
|
.collect();
|
|
ReadSchema {
|
|
columns,
|
|
primary_key: table.primary_key.clone(),
|
|
foreign_keys,
|
|
}
|
|
}
|
|
|
|
/// Read `csv_path` and INSERT each row into `table.name`.
|
|
/// Failures are wrapped in `DbError::RebuildRowFailed` with
|
|
/// row number and table name per ADR-0015 §7.
|
|
fn load_table_csv(
|
|
tx: &rusqlite::Transaction<'_>,
|
|
table: &TableSchema,
|
|
csv_path: &Path,
|
|
) -> Result<(), DbError> {
|
|
let body = std::fs::read_to_string(csv_path).map_err(|e| DbError::PersistenceFatal {
|
|
operation: "read",
|
|
path: csv_path.to_path_buf(),
|
|
message: e.to_string(),
|
|
})?;
|
|
let parsed = parse_csv(&body).map_err(|e| DbError::PersistenceFatal {
|
|
operation: "parse",
|
|
path: csv_path.to_path_buf(),
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if parsed.rows.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
// Header sanity check: column names must match the YAML
|
|
// schema's column order. A mismatch is a hand-edit hazard;
|
|
// surfacing it as a fatal error is better than silently
|
|
// mis-aligning columns.
|
|
let expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
|
|
let header_strs: Vec<&str> = parsed.header.iter().map(String::as_str).collect();
|
|
if header_strs != expected {
|
|
return Err(DbError::PersistenceFatal {
|
|
operation: "validate",
|
|
path: csv_path.to_path_buf(),
|
|
message: format!(
|
|
"CSV header {:?} does not match table columns {:?}",
|
|
parsed.header, expected,
|
|
),
|
|
});
|
|
}
|
|
|
|
let cols_csv = table
|
|
.columns
|
|
.iter()
|
|
.map(|c| quote_ident(&c.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let placeholders = (1..=table.columns.len())
|
|
.map(|i| format!("?{i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sql = format!(
|
|
"INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});",
|
|
ident = quote_ident(&table.name),
|
|
);
|
|
let mut stmt = tx.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
|
|
|
for (idx, raw_row) in parsed.rows.iter().enumerate() {
|
|
// Row number reported as a 1-based file line: header
|
|
// is line 1, so the first data row is line 2.
|
|
let row_number = idx + 2;
|
|
if raw_row.len() != table.columns.len() {
|
|
return Err(DbError::RebuildRowFailed {
|
|
table: table.name.clone(),
|
|
csv_path: csv_path.to_path_buf(),
|
|
row_number,
|
|
detail: format!(
|
|
"row has {} field(s) but table has {} column(s)",
|
|
raw_row.len(),
|
|
table.columns.len(),
|
|
),
|
|
});
|
|
}
|
|
let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(raw_row.len());
|
|
for (col, raw_cell) in table.columns.iter().zip(raw_row.iter()) {
|
|
let cell = decode_cell(col.user_type, raw_cell).map_err(|detail| {
|
|
DbError::RebuildRowFailed {
|
|
table: table.name.clone(),
|
|
csv_path: csv_path.to_path_buf(),
|
|
row_number,
|
|
detail: format!("column `{}`: {detail}", col.name),
|
|
}
|
|
})?;
|
|
params.push(cell_value_to_sqlite(&cell));
|
|
}
|
|
stmt.execute(rusqlite::params_from_iter(params.iter()))
|
|
.map_err(|e| DbError::RebuildRowFailed {
|
|
table: table.name.clone(),
|
|
csv_path: csv_path.to_path_buf(),
|
|
row_number,
|
|
detail: e.to_string(),
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
|
|
use rusqlite::types::Value;
|
|
match cell {
|
|
CellValue::Null => Value::Null,
|
|
CellValue::Integer(n) => Value::Integer(*n),
|
|
CellValue::Real(f) => Value::Real(*f),
|
|
CellValue::Text(s) => Value::Text(s.clone()),
|
|
CellValue::Blob(b) => Value::Blob(b.clone()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn db() -> Database {
|
|
Database::open(":memory:").expect("open in-memory")
|
|
}
|
|
|
|
fn col(name: &str, ty: Type) -> ColumnSpec {
|
|
ColumnSpec::new(name, ty)
|
|
}
|
|
|
|
/// Convenience: a `serial`-PK table with a single `id` column.
|
|
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
|
|
db.create_table(
|
|
name.to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.expect("create table")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_in_memory_succeeds() {
|
|
let _ = db();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_with_serial_pk_appears_in_list() {
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert_eq!(tables, vec!["Customers".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_with_serial_pk_describes_correctly() {
|
|
let db = db();
|
|
let desc = make_id_table(&db, "Customers").await;
|
|
assert_eq!(desc.name, "Customers");
|
|
assert_eq!(desc.columns.len(), 1);
|
|
let id = &desc.columns[0];
|
|
assert_eq!(id.name, "id");
|
|
assert!(id.primary_key);
|
|
assert_eq!(id.user_type, Some(Type::Serial));
|
|
assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_with_text_pk_works() {
|
|
let db = db();
|
|
let desc = db
|
|
.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("email", Type::Text)],
|
|
vec!["email".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(desc.columns.len(), 1);
|
|
assert_eq!(desc.columns[0].name, "email");
|
|
assert_eq!(desc.columns[0].user_type, Some(Type::Text));
|
|
assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
|
|
assert!(desc.columns[0].primary_key);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_with_compound_pk_works() {
|
|
let db = db();
|
|
let desc = db
|
|
.create_table(
|
|
"OrderLines".to_string(),
|
|
vec![col("order_id", Type::Int), col("product_id", Type::Int)],
|
|
vec!["order_id".to_string(), "product_id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(desc.columns.len(), 2);
|
|
assert!(desc.columns.iter().all(|c| c.primary_key));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
|
|
// The grammar lets users try anything; the DB layer just
|
|
// does what they ask.
|
|
let db = db();
|
|
let desc = db
|
|
.create_table(
|
|
"T".to_string(),
|
|
vec![col("flag", Type::Bool)],
|
|
vec!["flag".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert!(desc.columns[0].primary_key);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_rejects_zero_columns() {
|
|
let db = db();
|
|
let err = db
|
|
.create_table("T".to_string(), Vec::new(), Vec::new(), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_table_removes_it_from_list() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.drop_table("T".to_string(), None).await.unwrap();
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert!(tables.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_appends_to_existing_table() {
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
let result = db
|
|
.add_column("Customers".to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let desc = &result.description;
|
|
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
|
assert_eq!(names, vec!["id", "Name"]);
|
|
let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
|
|
assert_eq!(name_col.user_type, Some(Type::Text));
|
|
assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
|
|
assert!(result.client_side_notes.is_empty(), "no auto-fill for plain types");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_facing_types_round_trip_through_metadata() {
|
|
let db = db();
|
|
// Create with a serial PK and add columns of every type
|
|
// that would otherwise be erased by SQLite (date,
|
|
// datetime, decimal — all backed by TEXT).
|
|
make_id_table(&db, "T").await;
|
|
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
|
|
db.add_column("T".to_string(), ColumnSpec::new(format!("c_{ty}"), ty), None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
|
|
assert_eq!(id_col.user_type, Some(Type::Serial));
|
|
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
|
|
let col_name = format!("c_{ty}");
|
|
let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
|
|
assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_tables_excludes_internal_metadata_table() {
|
|
let db = db();
|
|
make_id_table(&db, "Visible").await;
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert_eq!(tables, vec!["Visible".to_string()]);
|
|
// Metadata table is present in the underlying schema but
|
|
// hidden from list_tables.
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
|
|
let db = db();
|
|
// Create with a date column.
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("when", Type::Date)],
|
|
vec!["when".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let before = db.describe_table("T".to_string(), None).await.unwrap();
|
|
assert_eq!(before.columns[0].user_type, Some(Type::Date));
|
|
|
|
// Drop it.
|
|
db.drop_table("T".to_string(), None).await.unwrap();
|
|
|
|
// Recreate with a different type for the same-named column;
|
|
// the metadata for the new table must reflect the new type
|
|
// (i.e. metadata from the previous incarnation must not
|
|
// bleed through).
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("when", Type::DateTime)],
|
|
vec!["when".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let after = db.describe_table("T".to_string(), None).await.unwrap();
|
|
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_for_each_value_type() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
|
|
let col_name = format!("c_{ty}");
|
|
db.add_column("T".to_string(), ColumnSpec::new(col_name.clone(), ty), None)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
|
|
}
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
// 5 user columns + the id PK column.
|
|
assert_eq!(desc.columns.len(), 6);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_serial_to_empty_table_succeeds() {
|
|
// ADR-0018 §6: serial / shortid via add_column are now
|
|
// allowed. Empty-table case: column added, no auto-fill
|
|
// [client-side] note (nothing to populate).
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
let result = db
|
|
.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
let code = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "code")
|
|
.expect("code column added");
|
|
assert_eq!(code.user_type, Some(Type::Serial));
|
|
assert!(
|
|
!code.primary_key,
|
|
"non-PK serial: column is not the PK (id remains the PK)"
|
|
);
|
|
assert!(
|
|
result.client_side_notes.is_empty(),
|
|
"empty table: no auto-fill note"
|
|
);
|
|
}
|
|
|
|
/// Helper: build a table with `id` (serial PK) + `Name`
|
|
/// (text) and insert N rows, populating just `Name`.
|
|
async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) {
|
|
make_id_table(db, table).await;
|
|
db.add_column(table.to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
for i in 0..count {
|
|
db.insert(
|
|
table.to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text(format!("row{i}"))],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_serial_to_non_empty_table_auto_fills() {
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 3).await;
|
|
let result = db
|
|
.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
let seq = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "seq")
|
|
.unwrap();
|
|
assert_eq!(seq.user_type, Some(Type::Serial));
|
|
assert!(!result.client_side_notes.is_empty(), "auto-fill note expected");
|
|
assert!(
|
|
result.client_side_notes[0].contains("3 row(s) given auto-generated serial"),
|
|
"unexpected note: {:?}",
|
|
result.client_side_notes
|
|
);
|
|
// Verify the column is populated 1..3.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
|
|
let mut filled: Vec<i64> = data
|
|
.rows
|
|
.iter()
|
|
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
|
|
.collect();
|
|
filled.sort();
|
|
assert_eq!(filled, vec![1, 2, 3]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_shortid_to_non_empty_table_auto_fills() {
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 3).await;
|
|
let result = db
|
|
.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::ShortId), None)
|
|
.await
|
|
.unwrap();
|
|
let tag = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "tag")
|
|
.unwrap();
|
|
assert_eq!(tag.user_type, Some(Type::ShortId));
|
|
assert!(!result.client_side_notes.is_empty(), "auto-fill note expected");
|
|
assert!(
|
|
result.client_side_notes[0].contains("3 row(s) given auto-generated shortid"),
|
|
"unexpected note: {:?}",
|
|
result.client_side_notes
|
|
);
|
|
// Verify each row has a non-null shortid value.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
|
|
for row in &data.rows {
|
|
let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled");
|
|
assert!(
|
|
v.len() >= 10 && v.len() <= 12,
|
|
"shortid length out of range: {v}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_auto_fills_non_pk_serial_column_sequentially() {
|
|
// ADR-0018 §5: non-PK serial columns get MAX(col)+1
|
|
// automatically when the user omits them. PK serial
|
|
// columns continue to use SQLite's rowid alias.
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
// Insert three rows providing only `Name`. The seq
|
|
// column should auto-fill 1, 2, 3.
|
|
for n in ["a", "b", "c"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text(n.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
|
|
let mut values: Vec<i64> = data
|
|
.rows
|
|
.iter()
|
|
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
|
|
.collect();
|
|
values.sort();
|
|
assert_eq!(values, vec![1, 2, 3]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_non_pk_serial_continues_past_explicit_value() {
|
|
// If the user explicitly inserts a high value, MAX+1
|
|
// sequencing jumps past it. Gappy sequences are
|
|
// accepted (ADR-0018 Resolution 2).
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
// Insert with explicit seq=100.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string(), "seq".to_string()]),
|
|
vec![Value::Text("a".to_string()), Value::Number("100".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Next omitted-seq insert should auto-fill 101.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text("b".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap();
|
|
let mut values: Vec<i64> = data
|
|
.rows
|
|
.iter()
|
|
.filter_map(|r| r[seq_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
|
|
.collect();
|
|
values.sort();
|
|
assert_eq!(values, vec![100, 101]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_serial_emits_unique_constraint() {
|
|
// Non-PK serial gains UNIQUE per ADR-0018 §4. Verify by
|
|
// attempting to UPDATE all rows to the same value and
|
|
// confirming the engine refuses.
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 2).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
// Attempt to UPDATE one row to have the same `seq` value
|
|
// as the other — should violate UNIQUE.
|
|
let err = db
|
|
.update(
|
|
"T".to_string(),
|
|
vec![("seq".to_string(), Value::Number("1".to_string()))],
|
|
RowFilter::AllRows,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
// SQLite reports as a constraint violation; classified
|
|
// as UniqueViolation.
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => {
|
|
assert_eq!(kind, SqliteErrorKind::UniqueViolation);
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_duplicate_returns_already_exists() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
let err = db
|
|
.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_nonexistent_table_returns_no_such_table() {
|
|
let db = db();
|
|
let err = db.drop_table("Ghost".to_string(), None).await.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_to_missing_table_returns_no_such_table() {
|
|
let db = db();
|
|
let err = db
|
|
.add_column("Ghost".to_string(), ColumnSpec::new("x".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
// --- drop_column / rename_column / change_column_type ---
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_removes_column_and_data() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"T".to_string(),
|
|
None,
|
|
vec![Value::Number("42".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db
|
|
.drop_column("T".to_string(), "Score".to_string(), false, None)
|
|
.await
|
|
.unwrap();
|
|
let names: Vec<_> = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.map(|c| c.name.as_str())
|
|
.collect();
|
|
assert_eq!(names, vec!["id"]);
|
|
|
|
// Row data still accessible (id was preserved); the
|
|
// dropped column is gone from the projection.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.columns, vec!["id".to_string()]);
|
|
assert_eq!(data.rows.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_refuses_primary_key() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
let err = db
|
|
.drop_column("T".to_string(), "id".to_string(), false, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
let msg = format!("{err}");
|
|
assert!(msg.to_lowercase().contains("primary"), "{msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_refuses_column_in_a_relationship() {
|
|
let db = db();
|
|
// Customers(id PK) ← Orders(cust_id FK)
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Try to drop the FK column on the child side.
|
|
let err = db
|
|
.drop_column("Orders".to_string(), "cust_id".to_string(), false, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
assert!(format!("{err}").contains("relationship"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_for_missing_column_errors() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
let err = db
|
|
.drop_column("T".to_string(), "Ghost".to_string(), false, None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchColumn),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
// --- indexes (ADR-0025) -----------------------------------
|
|
|
|
/// A `serial`-PK table with one extra text `Email` column —
|
|
/// something indexable.
|
|
async fn make_indexable_table(db: &Database, name: &str) {
|
|
make_id_table(db, name).await;
|
|
db.add_column(name.to_string(), ColumnSpec::new("Email".to_string(), Type::Text), None)
|
|
.await
|
|
.expect("add Email column");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_appears_in_description() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
let desc = db
|
|
.add_index(
|
|
Some("idx_email".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add index");
|
|
assert_eq!(desc.indexes.len(), 1);
|
|
assert_eq!(desc.indexes[0].name, "idx_email");
|
|
assert_eq!(desc.indexes[0].columns, vec!["Email".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_auto_generates_name() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
let desc = db
|
|
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
|
|
.await
|
|
.expect("add index");
|
|
assert_eq!(desc.indexes[0].name, "Customers_Email_idx");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_composite_auto_name_joins_columns() {
|
|
let db = db();
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("Day".to_string(), Type::Date), None)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.add_index(
|
|
None,
|
|
"Orders".to_string(),
|
|
vec!["CustId".to_string(), "Day".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add index");
|
|
assert_eq!(desc.indexes[0].name, "Orders_CustId_Day_idx");
|
|
assert_eq!(
|
|
desc.indexes[0].columns,
|
|
vec!["CustId".to_string(), "Day".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_rejects_duplicate_name() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_column("Customers".to_string(), ColumnSpec::new("Nick".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_index(
|
|
Some("idx".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.add_index(
|
|
Some("idx".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Nick".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_rejects_redundant_column_set() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_index(
|
|
Some("a".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.add_index(
|
|
Some("b".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_rejects_missing_column() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
let err = db
|
|
.add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DbError::Sqlite {
|
|
kind: SqliteErrorKind::NoSuchColumn,
|
|
..
|
|
}
|
|
),
|
|
"got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_index_rejects_missing_table() {
|
|
let db = db();
|
|
let err = db
|
|
.add_index(None, "Ghost".to_string(), vec!["x".to_string()], None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
DbError::Sqlite {
|
|
kind: SqliteErrorKind::NoSuchTable,
|
|
..
|
|
}
|
|
),
|
|
"got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_index_by_name_removes_it() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_index(
|
|
Some("idx_email".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.drop_index(
|
|
IndexSelector::Named {
|
|
name: "idx_email".to_string(),
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.expect("drop index");
|
|
assert!(desc.indexes.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_index_by_columns_removes_it() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.drop_index(
|
|
IndexSelector::Columns {
|
|
table: "Customers".to_string(),
|
|
columns: vec!["Email".to_string()],
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.expect("drop index");
|
|
assert!(desc.indexes.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_index_unknown_name_errors() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
let err = db
|
|
.drop_index(
|
|
IndexSelector::Named {
|
|
name: "nope".to_string(),
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Sqlite { .. }), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_refuses_indexed_column_without_cascade() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_index(
|
|
Some("idx_email".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.drop_column("Customers".to_string(), "Email".to_string(), false, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
assert!(format!("{err}").contains("idx_email"), "got {err}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_column_cascade_drops_covering_index() {
|
|
let db = db();
|
|
make_indexable_table(&db, "Customers").await;
|
|
db.add_index(
|
|
Some("idx_email".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let result = db
|
|
.drop_column("Customers".to_string(), "Email".to_string(), true, None)
|
|
.await
|
|
.expect("drop column --cascade");
|
|
assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]);
|
|
assert!(result.description.indexes.is_empty());
|
|
assert!(
|
|
result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.all(|c| c.name != "Email"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_table_preserves_indexes() {
|
|
// `change column` rebuilds the table; an index on an
|
|
// unrelated column must survive the rebuild (ADR-0025).
|
|
let db = db();
|
|
make_indexable_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_index(
|
|
Some("idx_email".to_string()),
|
|
"T".to_string(),
|
|
vec!["Email".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Score".to_string(),
|
|
Type::Real,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("change column type");
|
|
assert_eq!(result.description.indexes.len(), 1);
|
|
assert_eq!(result.description.indexes[0].name, "idx_email");
|
|
assert_eq!(
|
|
result.description.indexes[0].columns,
|
|
vec!["Email".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_column_updates_schema_and_metadata() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Old".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.rename_column("T".to_string(), "Old".to_string(), "New".to_string(), None)
|
|
.await
|
|
.unwrap();
|
|
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
|
assert_eq!(names, vec!["id", "New"]);
|
|
// user_type metadata was preserved through the rename.
|
|
let new_col = desc.columns.iter().find(|c| c.name == "New").unwrap();
|
|
assert_eq!(new_col.user_type, Some(Type::Text));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_column_propagates_to_relationship_metadata() {
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Rename on the child side; SQLite cascades the FK
|
|
// declaration, and we mirror that into our metadata.
|
|
db.rename_column(
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
"buyer_id".to_string(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let orders = db
|
|
.describe_table("Orders".to_string(), None)
|
|
.await
|
|
.unwrap();
|
|
let outbound = orders
|
|
.outbound_relationships
|
|
.iter()
|
|
.find(|r| r.local_column == "buyer_id");
|
|
assert!(
|
|
outbound.is_some(),
|
|
"expected outbound rel on `buyer_id`, got {:?}",
|
|
orders.outbound_relationships,
|
|
);
|
|
|
|
// Same from the parent perspective via inbound.
|
|
let customers = db
|
|
.describe_table("Customers".to_string(), None)
|
|
.await
|
|
.unwrap();
|
|
let inbound = customers
|
|
.inbound_relationships
|
|
.iter()
|
|
.find(|r| r.other_column == "buyer_id");
|
|
assert!(
|
|
inbound.is_some(),
|
|
"expected inbound rel referencing `buyer_id`, got {:?}",
|
|
customers.inbound_relationships,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_column_refuses_collision() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("T".to_string(), ColumnSpec::new("B".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.rename_column("T".to_string(), "A".to_string(), "B".to_string(), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_column_refuses_identity_rename() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.rename_column("T".to_string(), "A".to_string(), "A".to_string(), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_works_for_compatible_data() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
// Insert numeric-looking strings.
|
|
for v in ["1", "2", "3"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Score".to_string()]),
|
|
vec![Value::Text(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Score".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let score = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "Score")
|
|
.unwrap();
|
|
assert_eq!(score.user_type, Some(Type::Int));
|
|
// Per ADR-0017 §6, the [client-side] note fires when
|
|
// any cell was rewritten (storage class change counts).
|
|
let note = result
|
|
.client_side
|
|
.expect("text -> int rewrites every cell; expected client-side note");
|
|
assert_eq!(note.transformed, 3);
|
|
assert_eq!(note.lossy, 0);
|
|
// Data preserved via the per-cell transformer.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows.len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_pk_without_inbound_fk_allowed() {
|
|
// ADR-0017 §4.1: a PK without inbound FKs may have its
|
|
// type changed freely (no cascade to worry about).
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
// serial -> int (canonical "drop auto-increment" case).
|
|
db.change_column_type(
|
|
"T".to_string(),
|
|
"id".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("serial -> int on PK without inbound FK should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_pk_with_inbound_fk_refused_when_target_type_changes() {
|
|
// PK referenced by another table's FK; new type would
|
|
// change `fk_target_type` (serial -> text means
|
|
// fk_target_type goes Int -> Text). Refused.
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type(
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
Type::Text,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("referenced") || message.contains("relationship"),
|
|
"expected FK-cascade language: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_pk_with_inbound_fk_allowed_when_target_type_preserved() {
|
|
// PK referenced by another table's FK; serial -> int
|
|
// preserves `fk_target_type` (both Int). Allowed.
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.change_column_type(
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("serial -> int on FK-referenced PK should succeed");
|
|
}
|
|
|
|
// ---- ADR-0017 dry-run / mode / uniqueness coverage ----
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_refuses_lossy_by_default() {
|
|
// real -> int with fractional values: every cell is
|
|
// Lossy. Default mode refuses with a friendly diagnostic
|
|
// table.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["3.14", "2.71"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Score".to_string()]),
|
|
vec![Value::Number(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Score".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("discard information"),
|
|
"expected lossy header: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("--force-conversion"),
|
|
"expected hint about --force-conversion: {message}"
|
|
);
|
|
// Bordered diagnostic table is present.
|
|
assert!(message.contains('┌'), "expected bordered table: {message}");
|
|
assert!(
|
|
message.contains("(PK)"),
|
|
"expected PK header marker: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_force_conversion_accepts_lossy() {
|
|
// Same setup as above, but with --force-conversion the
|
|
// change succeeds and the [client-side] note carries
|
|
// the lossy count.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["3.14", "2.71", "5.0"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Score".to_string()]),
|
|
vec![Value::Number(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Score".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::ForceConversion,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("--force-conversion should accept lossy rows");
|
|
let note = result.client_side.expect("transformations happened");
|
|
assert_eq!(note.transformed, 3);
|
|
// Two of three rows are fractional (lossy); 5.0 is clean.
|
|
assert_eq!(note.lossy, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_refuses_incompatible_even_with_force() {
|
|
// text "abc" -> int: incompatible. --force-conversion
|
|
// does NOT help (per ADR-0017 §5 / §2 step 3).
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["abc", "123", "xyz"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Note".to_string()]),
|
|
vec![Value::Text(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Note".to_string(),
|
|
Type::Int,
|
|
mode,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("cannot be converted"),
|
|
"expected incompatible header for {mode:?}: {message}"
|
|
);
|
|
assert!(
|
|
!message.contains("--force-conversion"),
|
|
"incompatible refusal must NOT mention --force-conversion: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected ({mode:?}): {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_int_to_bool_with_zero_one_succeeds() {
|
|
// ADR-0017 §3 matrix: (Int, Bool) is per-cell-classified.
|
|
// Values 0/1 are Clean (storage class doesn't change); the
|
|
// transformer returns Value::Integer(0)/(1) unchanged, so
|
|
// is_non_identity is false for every cell. No
|
|
// [client-side] note is expected.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["0", "1", "0"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Flag".to_string()]),
|
|
vec![Value::Number(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Flag".to_string(),
|
|
Type::Bool,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("int -> bool with all 0/1 values should succeed");
|
|
let flag = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "Flag")
|
|
.unwrap();
|
|
assert_eq!(flag.user_type, Some(Type::Bool));
|
|
assert!(
|
|
result.client_side.is_none(),
|
|
"int -> bool with values that map identity should not fire a client-side note: {:?}",
|
|
result.client_side
|
|
);
|
|
// Data preserved.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows.len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_int_to_bool_refuses_other_values() {
|
|
// ADR-0017 §3 matrix: (Int, Bool) classifies any value
|
|
// other than 0/1 as Incompatible. A single offending row
|
|
// triggers a refusal, and --force-conversion does not
|
|
// help (incompatible is not lossy).
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["0", "1", "2"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Flag".to_string()]),
|
|
vec![Value::Number(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] {
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Flag".to_string(),
|
|
Type::Bool,
|
|
mode,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("cannot be converted"),
|
|
"expected incompatible header for {mode:?}: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("not 0 or 1"),
|
|
"expected per-cell reason naming the offending value for {mode:?}: {message}"
|
|
);
|
|
assert!(
|
|
!message.contains("--force-conversion"),
|
|
"incompatible refusal must NOT advertise --force-conversion for {mode:?}: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected ({mode:?}): {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_dont_convert_skips_client_side() {
|
|
// text -> int: under the per-cell matrix, "1"/"2"/"3"
|
|
// would all classify Clean and fire the [client-side]
|
|
// note. With --dont-convert we bypass that and rely on
|
|
// engine coercion; the note is suppressed.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
for v in ["1", "2", "3"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Score".to_string()]),
|
|
vec![Value::Text(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Score".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::DontConvert,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("text->int with all-clean cells should succeed under --dont-convert");
|
|
assert!(
|
|
result.client_side.is_none(),
|
|
"--dont-convert must NOT emit a client-side note"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_uniqueness_collision_on_pk_refused() {
|
|
// PK column with values 3.14 and 3.7: real -> int with
|
|
// --force-conversion would collapse both to 3, violating
|
|
// PK uniqueness. ADR-0017 §4.3: this is incompatible
|
|
// even under --force-conversion.
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Real)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for v in ["3.14", "3.7"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string()]),
|
|
vec![Value::Number(v.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"id".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::ForceConversion,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("collision") && message.contains("uniqueness"),
|
|
"expected collision diagnostic: {message}"
|
|
);
|
|
assert!(message.contains('┌'), "expected bordered table: {message}");
|
|
// Source rows column with PK column name in the header.
|
|
assert!(
|
|
message.contains("Source rows (id)"),
|
|
"expected PK-aware Source rows header: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_blob_target_refused_statically() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Note".to_string(),
|
|
Type::Blob,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_outbound_fk_refused() {
|
|
// Child-side FK column: §4.2 says always refuse,
|
|
// regardless of target type.
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type(
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
Type::Real,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("foreign key") || message.contains("relationship"),
|
|
"expected outbound-FK refusal: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_no_op_when_no_rows() {
|
|
// Empty table: no cells to transform, no [client-side]
|
|
// note, and the structural change goes through.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"Note".to_string(),
|
|
Type::Int,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("empty table should still allow the type change");
|
|
assert!(
|
|
result.client_side.is_none(),
|
|
"no rows means no client-side note"
|
|
);
|
|
let note_col = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "Note")
|
|
.unwrap();
|
|
assert_eq!(note_col.user_type, Some(Type::Int));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_int_to_serial_succeeds() {
|
|
// ADR-0018 §8: int → serial joins the matrix. The
|
|
// column gains UNIQUE (since it's non-PK) and the
|
|
// existing values are preserved.
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
// Insert a few rows with explicit code values.
|
|
for (i, code) in [(1, 10), (2, 20), (3, 30)] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string(), "code".to_string()]),
|
|
vec![
|
|
Value::Text(format!("row{i}")),
|
|
Value::Number(code.to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"code".to_string(),
|
|
Type::Serial,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("int -> serial should succeed");
|
|
let code = result
|
|
.description
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "code")
|
|
.unwrap();
|
|
assert_eq!(code.user_type, Some(Type::Serial));
|
|
// No null cells, no transformation: no [client-side]
|
|
// note (storage-class change for int -> serial is
|
|
// identity at the value level).
|
|
assert!(
|
|
result.client_side.is_none(),
|
|
"int -> serial with all non-null values is a metadata-only change"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_int_to_serial_refuses_duplicate_values() {
|
|
// Duplicate non-null values would violate the UNIQUE
|
|
// contract serial gains. Refused via the existing
|
|
// uniqueness-collision diagnostic.
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
// Two rows with the same code.
|
|
for i in 0..2 {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string(), "code".to_string()]),
|
|
vec![Value::Text(format!("row{i}")), Value::Number("7".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"code".to_string(),
|
|
Type::Serial,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("collision") || message.contains("uniqueness"),
|
|
"expected uniqueness diagnostic: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_text_to_serial_routes_via_int_hint() {
|
|
// Per ADR-0018 §8, only int → serial is supported
|
|
// directly. Other source types are refused with a hint
|
|
// to route through int.
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"A".to_string(),
|
|
Type::Serial,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(message) => {
|
|
assert!(
|
|
message.contains("int"),
|
|
"expected hint about routing via int: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_int_to_serial_with_nulls_auto_fills() {
|
|
// Per ADR-0018 §3 / §7, null cells get sequence values
|
|
// continuing from MAX of non-null values. The
|
|
// [client-side] note reports the auto-fill count.
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
// Three rows: one with code=5, two with NULL.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string(), "code".to_string()]),
|
|
vec![Value::Text("a".to_string()), Value::Number("5".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for n in ["b", "c"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text(n.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"code".to_string(),
|
|
Type::Serial,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("int -> serial with auto-fill should succeed");
|
|
let note = result
|
|
.client_side
|
|
.expect("auto-fill should produce a client-side note");
|
|
assert_eq!(note.auto_filled, 2);
|
|
assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial));
|
|
// Confirm the filled values: existing 5, fills are 6
|
|
// and 7 (continue sequence from MAX+1).
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let code_idx = data.columns.iter().position(|c| c == "code").unwrap();
|
|
let mut values: Vec<i64> = data
|
|
.rows
|
|
.iter()
|
|
.filter_map(|r| r[code_idx].as_ref().and_then(|s| s.parse::<i64>().ok()))
|
|
.collect();
|
|
values.sort();
|
|
assert_eq!(values, vec![5, 6, 7]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_text_to_shortid_with_nulls_auto_fills() {
|
|
// text → shortid is in the matrix; null cells in the
|
|
// text column get fresh shortids (ADR-0018 §3).
|
|
let db = db();
|
|
make_table_with_n_rows(&db, "T", 0).await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
// One row with a valid shortid value, two with NULL.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string(), "tag".to_string()]),
|
|
vec![
|
|
Value::Text("a".to_string()),
|
|
Value::Text("23456789Ab".to_string()),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for n in ["b", "c"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text(n.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.change_column_type(
|
|
"T".to_string(),
|
|
"tag".to_string(),
|
|
Type::ShortId,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("text -> shortid with auto-fill should succeed");
|
|
let note = result
|
|
.client_side
|
|
.expect("auto-fill should produce a client-side note");
|
|
assert_eq!(note.auto_filled, 2);
|
|
assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId));
|
|
// All three rows now have valid shortids.
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap();
|
|
for row in &data.rows {
|
|
let v = row[tag_idx].as_ref().expect("non-null shortid after fill");
|
|
assert!(v.len() >= 10 && v.len() <= 12, "len out of range: {v}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_refuses_relationship_column() {
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type(
|
|
"Orders".to_string(),
|
|
"cust_id".to_string(),
|
|
Type::Text,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn change_column_type_no_op_to_same_type_errors() {
|
|
let db = db();
|
|
make_id_table(&db, "T").await;
|
|
db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.change_column_type("T".to_string(), "A".to_string(), Type::Int, ChangeColumnMode::Default, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn describe_missing_table_returns_no_such_table() {
|
|
let db = db();
|
|
let err = db.describe_table("Ghost".to_string(), None).await.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
// --- Relationship tests ---
|
|
|
|
async fn customers_orders_setup(db: &Database) {
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial), col("Name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_appears_outbound_on_child() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.describe_table("Orders".to_string(), None).await.unwrap();
|
|
assert_eq!(orders.outbound_relationships.len(), 1);
|
|
let rel = &orders.outbound_relationships[0];
|
|
assert_eq!(rel.local_column, "CustId");
|
|
assert_eq!(rel.other_table, "Customers");
|
|
assert_eq!(rel.other_column, "id");
|
|
assert_eq!(rel.name, "Customers_id_to_Orders_CustId");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_appears_inbound_on_parent() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
|
|
assert_eq!(customers.inbound_relationships.len(), 1);
|
|
let rel = &customers.inbound_relationships[0];
|
|
assert_eq!(rel.local_column, "id");
|
|
assert_eq!(rel.other_table, "Orders");
|
|
assert_eq!(rel.other_column, "CustId");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_with_user_supplied_name() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
Some("cust_orders".to_string()),
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::Cascade,
|
|
ReferentialAction::SetNull,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.describe_table("Orders".to_string(), None).await.unwrap();
|
|
let rel = &orders.outbound_relationships[0];
|
|
assert_eq!(rel.name, "cust_orders");
|
|
assert_eq!(rel.on_delete, ReferentialAction::Cascade);
|
|
assert_eq!(rel.on_update, ReferentialAction::SetNull);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_with_create_fk_creates_the_column() {
|
|
let db = db();
|
|
// Create child *without* the FK column.
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
true, // --create-fk
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.describe_table("Orders".to_string(), None).await.unwrap();
|
|
// The auto-created FK column has user_type Int (Serial's
|
|
// fk_target_type), not Serial.
|
|
let cust = orders
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "CustId")
|
|
.expect("FK column auto-created");
|
|
assert_eq!(cust.user_type, Some(Type::Int));
|
|
assert_eq!(orders.outbound_relationships.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_without_create_fk_errors_when_column_missing() {
|
|
let db = db();
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_with_type_mismatch_errors_with_advice() {
|
|
let db = db();
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
// Wrong type — text instead of int.
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = db
|
|
.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(msg) => {
|
|
assert!(msg.contains("type mismatch"), "{msg}");
|
|
assert!(msg.contains("text") && msg.contains("int"), "{msg}");
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_against_non_pk_errors() {
|
|
let db = db();
|
|
// Customers has a non-PK column we'll try to point at.
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial), col("Name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("CustName".to_string(), Type::Text), None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"Name".to_string(),
|
|
"Orders".to_string(),
|
|
"CustName".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(msg) => assert!(msg.contains("not a primary key"), "{msg}"),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_relationship_by_name_clears_both_sides() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
Some("cust_orders".to_string()),
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.drop_relationship(RelationshipSelector::Named {
|
|
name: "cust_orders".to_string(),
|
|
}, None)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.describe_table("Orders".to_string(), None).await.unwrap();
|
|
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
|
|
assert!(orders.outbound_relationships.is_empty());
|
|
assert!(customers.inbound_relationships.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_relationship_by_endpoints_works() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.drop_relationship(RelationshipSelector::Endpoints {
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "CustId".to_string(),
|
|
}, None)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.describe_table("Orders".to_string(), None).await.unwrap();
|
|
assert!(orders.outbound_relationships.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_table_with_inbound_relationship_errors() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let err = db.drop_table("Customers".to_string(), None).await.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(msg) => {
|
|
assert!(msg.contains("referenced by"), "{msg}");
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_child_table_cleans_relationship_metadata() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
// Dropping the child is allowed (no inbound relationships
|
|
// on Orders) and cleans the metadata.
|
|
db.drop_table("Orders".to_string(), None).await.unwrap();
|
|
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
|
|
assert!(customers.inbound_relationships.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_relationship_with_duplicate_name_errors() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
db.add_column("Orders".to_string(), ColumnSpec::new("OtherCust".to_string(), Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
Some("dup".to_string()),
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let err = db
|
|
.add_relationship(
|
|
Some("dup".to_string()),
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"OtherCust".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Unsupported(msg) => assert!(msg.contains("already exists"), "{msg}"),
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_preserves_existing_data_through_relationship_change() {
|
|
let db = db();
|
|
customers_orders_setup(&db).await;
|
|
// Direct INSERT through the underlying connection isn't
|
|
// exposed via the public API yet (C5). The point of this
|
|
// test is to verify the rebuild itself works on populated
|
|
// tables — we add a column with a default-able operation
|
|
// and then add a relationship to ensure the table survives.
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::Cascade,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
// After the rebuild, the original columns are still
|
|
// present with the right user types, and any extra
|
|
// metadata (Name on Customers) survives.
|
|
let customers = db.describe_table("Customers".to_string(), None).await.unwrap();
|
|
let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect();
|
|
assert_eq!(names, vec!["id", "Name"]);
|
|
let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap();
|
|
assert_eq!(name_col.user_type, Some(Type::Text));
|
|
}
|
|
|
|
// --- Data operations (C5) ---
|
|
|
|
async fn customers_table(db: &Database) {
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial), col("Name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_short_form_skips_serial_column() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
let result = db
|
|
.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
vec![Value::Text("Alice".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1);
|
|
// The InsertResult itself carries the just-inserted row.
|
|
assert_eq!(result.data.rows.len(), 1);
|
|
assert_eq!(result.data.rows[0][1], Some("Alice".to_string()));
|
|
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]);
|
|
assert_eq!(data.rows.len(), 1);
|
|
assert_eq!(data.rows[0][1], Some("Alice".to_string()));
|
|
// id was auto-filled by SQLite.
|
|
assert_eq!(data.rows[0][0], Some("1".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_short_form_auto_generates_shortid() {
|
|
let db = db();
|
|
db.create_table(
|
|
"Tags".to_string(),
|
|
vec![col("id", Type::ShortId), col("Label", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Tags".to_string(),
|
|
None,
|
|
vec![Value::Text("database".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let data = db.query_data("Tags".to_string(), None, None, None).await.unwrap();
|
|
let id = data.rows[0][0].as_ref().expect("auto-generated id");
|
|
assert!(
|
|
id.len() >= 10 && id.len() <= 12,
|
|
"expected a base58 shortid, got {id}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_explicit_columns_uses_user_values() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
Some(vec!["id".to_string(), "Name".to_string()]),
|
|
vec![Value::Number("99".to_string()), Value::Text("Bob".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows[0][0], Some("99".to_string()));
|
|
assert_eq!(data.rows[0][1], Some("Bob".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_with_type_mismatch_returns_invalid_value() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
// 42 is a number, but Name expects text.
|
|
let err = db
|
|
.insert(
|
|
"Customers".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Number("42".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_with_where_affects_only_matching_rows() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
for name in ["Alice", "Bob"] {
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
vec![Value::Text(name.to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.update(
|
|
"Customers".to_string(),
|
|
vec![("Name".to_string(), Value::Text("Alicia".to_string()))],
|
|
RowFilter::eq("id", Value::Number("1".to_string())),
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1);
|
|
// The UpdateResult contains only the updated rows.
|
|
assert_eq!(result.data.rows.len(), 1);
|
|
assert_eq!(result.data.rows[0][1], Some("Alicia".to_string()));
|
|
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows[0][1], Some("Alicia".to_string()));
|
|
assert_eq!(data.rows[1][1], Some("Bob".to_string()));
|
|
}
|
|
|
|
// ---- Complex WHERE expressions (ADR-0026) ----------------
|
|
|
|
/// `People(id serial pk, Name text, Age int, Active bool)`
|
|
/// seeded with four rows: Alice/25/true, Bob/35/false,
|
|
/// Carol/45/true, Dave/35/true (ids 1..4).
|
|
async fn people_table(db: &Database) {
|
|
db.create_table(
|
|
"People".to_string(),
|
|
vec![
|
|
col("id", Type::Serial),
|
|
col("Name", Type::Text),
|
|
col("Age", Type::Int),
|
|
col("Active", Type::Bool),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for (name, age, active) in [
|
|
("Alice", 25, true),
|
|
("Bob", 35, false),
|
|
("Carol", 45, true),
|
|
("Dave", 35, true),
|
|
] {
|
|
db.insert(
|
|
"People".to_string(),
|
|
None,
|
|
vec![
|
|
Value::Text(name.to_string()),
|
|
Value::Number(age.to_string()),
|
|
Value::Bool(active),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
/// Pull the `RowFilter` out of an `update` / `delete` parsed
|
|
/// from DSL — the readable way to build a complex `Expr`.
|
|
fn parse_filter(dsl: &str) -> RowFilter {
|
|
match crate::dsl::parser::parse_command(dsl).expect("filter parse") {
|
|
crate::dsl::command::Command::Update { filter, .. }
|
|
| crate::dsl::command::Command::Delete { filter, .. } => filter,
|
|
other => panic!("expected update/delete, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Pull the optional filter + limit out of a parsed
|
|
/// `show data` command.
|
|
fn parse_show(dsl: &str) -> (Option<Expr>, Option<u64>) {
|
|
match crate::dsl::parser::parse_command(dsl).expect("show parse") {
|
|
crate::dsl::command::Command::ShowData { filter, limit, .. } => {
|
|
(filter, limit)
|
|
}
|
|
other => panic!("expected show data, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The `Name` column of every remaining row, in row order.
|
|
fn names(data: &DataResult) -> Vec<String> {
|
|
data.rows
|
|
.iter()
|
|
.map(|r| r[1].clone().unwrap_or_default())
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_and_expression_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter(
|
|
"delete from People where Age >= 35 and Active = true",
|
|
),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Carol (45/true) and Dave (35/true) match; Bob (35) is
|
|
// inactive, Alice (25) is too young.
|
|
assert_eq!(result.rows_affected, 2);
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Alice", "Bob"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_or_expression_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter("delete from People where id = 1 or id = 3"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 2);
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Bob", "Dave"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_not_expression_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
// `not Age = 35` keeps Bob and Dave (both 35).
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter("delete from People where not Age = 35"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 2);
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Bob", "Dave"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_between_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter("delete from People where Age between 30 and 40"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Bob (35) and Dave (35) are in range.
|
|
assert_eq!(result.rows_affected, 2);
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Alice", "Carol"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_in_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter(
|
|
"delete from People where Name in ('Alice', 'Carol')",
|
|
),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 2);
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Bob", "Dave"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_like_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.delete(
|
|
"People".to_string(),
|
|
parse_filter("delete from People where Name like 'A%'"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1, "only Alice matches `A%`");
|
|
let data = db.query_data("People".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_with_complex_where_updates_only_matching_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
// Deactivate everyone over 30 who is still active.
|
|
let result = db
|
|
.update(
|
|
"People".to_string(),
|
|
vec![("Active".to_string(), Value::Bool(false))],
|
|
parse_filter(
|
|
"update People set Active=false \
|
|
where Age > 30 and Active = true",
|
|
),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Carol (45) and Dave (35) — Bob is already inactive.
|
|
assert_eq!(result.rows_affected, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_data_with_where_filters_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let (filter, limit) = parse_show("show data People where Active = true");
|
|
let data = db
|
|
.query_data("People".to_string(), filter, limit, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_data_with_limit_caps_rows_by_primary_key() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let (filter, limit) = parse_show("show data People limit 2");
|
|
let data = db
|
|
.query_data("People".to_string(), filter, limit, None)
|
|
.await
|
|
.unwrap();
|
|
// `limit` implies an ORDER BY the primary key, so this
|
|
// is a stable "first 2 by id".
|
|
assert_eq!(names(&data), vec!["Alice", "Bob"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_data_with_where_and_limit_combines_both() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let (filter, limit) =
|
|
parse_show("show data People where Age >= 35 limit 1");
|
|
let data = db
|
|
.query_data("People".to_string(), filter, limit, None)
|
|
.await
|
|
.unwrap();
|
|
// Three rows match `Age >= 35` (Bob, Carol, Dave); the
|
|
// limit keeps the first by primary key — Bob (id 2).
|
|
assert_eq!(names(&data), vec!["Bob"]);
|
|
}
|
|
|
|
// --- explain / query plans (ADR-0028) -------------------
|
|
|
|
/// Parse a non-`explain` query command for use as the inner
|
|
/// command of `explain_query_plan`.
|
|
fn parse_inner(dsl: &str) -> Command {
|
|
crate::dsl::parser::parse_command(dsl).expect("inner command parse")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_show_data_returns_a_scan_plan() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("show data People where Active = true"))
|
|
.await
|
|
.unwrap();
|
|
assert!(!plan.rows.is_empty(), "a plan has at least one node");
|
|
// No index on `Active`, so the filtered query is a full
|
|
// table scan.
|
|
assert!(
|
|
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
|
|
"expected a SCAN node, got {:?}",
|
|
plan.rows,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_show_data_uses_an_index_when_one_exists() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
|
|
.await
|
|
.unwrap();
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("show data People where Age = 35"))
|
|
.await
|
|
.unwrap();
|
|
// The teaching payoff: the plan flips from a scan to an
|
|
// index search once an index covers the WHERE column.
|
|
assert!(
|
|
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
|
|
"expected an index search, got {:?}",
|
|
plan.rows,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_delete_does_not_remove_any_rows() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("delete from People where Active = true"))
|
|
.await
|
|
.unwrap();
|
|
assert!(!plan.rows.is_empty());
|
|
// ADR-0028 §1: EXPLAIN QUERY PLAN never executes.
|
|
let data = db
|
|
.query_data("People".to_string(), None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(data.rows.len(), 4, "explain delete must not delete");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_update_does_not_change_any_data() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.explain_query_plan(parse_inner(
|
|
"update People set Active = false where Active = true",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let (filter, _) = parse_show("show data People where Active = true");
|
|
let data = db
|
|
.query_data("People".to_string(), filter, None, None)
|
|
.await
|
|
.unwrap();
|
|
// Alice, Carol, Dave are still active — nothing ran.
|
|
assert_eq!(data.rows.len(), 3, "explain update must not write");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_display_sql_inlines_literals_and_quotes_idents() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("show data People where Name = 'Alice'"))
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
plan.display_sql.contains("\"People\""),
|
|
"table identifier should be double-quoted: {}",
|
|
plan.display_sql,
|
|
);
|
|
assert!(
|
|
plan.display_sql.contains("'Alice'"),
|
|
"WHERE literal should be inlined: {}",
|
|
plan.display_sql,
|
|
);
|
|
assert!(
|
|
!plan.display_sql.contains('?'),
|
|
"display SQL must carry no `?` placeholders: {}",
|
|
plan.display_sql,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_display_sql_shows_the_implicit_order_by_for_limit() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("show data People limit 2"))
|
|
.await
|
|
.unwrap();
|
|
// `limit` adds an implicit ORDER BY the primary key
|
|
// (ADR-0026 §5) — the display SQL surfaces it.
|
|
assert!(
|
|
plan.display_sql.contains("ORDER BY"),
|
|
"limit implies ORDER BY pk: {}",
|
|
plan.display_sql,
|
|
);
|
|
assert!(
|
|
plan.display_sql.contains("LIMIT 2"),
|
|
"limit count inlined: {}",
|
|
plan.display_sql,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_display_sql_writes_inequality_as_angle_brackets() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let plan = db
|
|
.explain_query_plan(parse_inner("show data People where Age != 35"))
|
|
.await
|
|
.unwrap();
|
|
// ADR-0028 §3: inequality is shown as standard SQL `<>`
|
|
// even when the user typed `!=`.
|
|
assert!(
|
|
plan.display_sql.contains("<>"),
|
|
"inequality should render as `<>`: {}",
|
|
plan.display_sql,
|
|
);
|
|
assert!(
|
|
!plan.display_sql.contains("!="),
|
|
"the `!=` spelling should not survive: {}",
|
|
plan.display_sql,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explain_of_a_missing_table_is_an_error() {
|
|
let db = db();
|
|
let result = db
|
|
.explain_query_plan(parse_inner("show data NoSuchTable"))
|
|
.await;
|
|
assert!(result.is_err(), "explaining a missing table should fail");
|
|
}
|
|
|
|
// --- column constraints at create-table (ADR-0029) ------
|
|
|
|
/// A `ColumnSpec` carrying the four constraint slots.
|
|
fn col_c(
|
|
name: &str,
|
|
ty: Type,
|
|
not_null: bool,
|
|
unique: bool,
|
|
default: Option<Value>,
|
|
) -> ColumnSpec {
|
|
ColumnSpec {
|
|
name: name.to_string(),
|
|
ty,
|
|
not_null,
|
|
unique,
|
|
default,
|
|
check: None,
|
|
}
|
|
}
|
|
|
|
/// Create `T(id serial pk, <extra>)`.
|
|
async fn table_with(db: &Database, extra: ColumnSpec) -> TableDescription {
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial), extra],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create table")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_not_null_column_rejects_a_null_insert() {
|
|
let db = db();
|
|
table_with(&db, col_c("name", Type::Text, true, false, None)).await;
|
|
let ok = db
|
|
.insert(
|
|
"T".to_string(),
|
|
Some(vec!["name".to_string()]),
|
|
vec![Value::Text("Alice".to_string())],
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(ok.is_ok(), "a non-null value is accepted");
|
|
let bad = db
|
|
.insert(
|
|
"T".to_string(),
|
|
Some(vec!["name".to_string()]),
|
|
vec![Value::Null],
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(bad.is_err(), "NULL into a NOT NULL column is refused");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_unique_column_rejects_a_duplicate_insert() {
|
|
let db = db();
|
|
table_with(&db, col_c("email", Type::Text, false, true, None)).await;
|
|
let insert_email = |v: &str| {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["email".to_string()]),
|
|
vec![Value::Text(v.to_string())],
|
|
None,
|
|
)
|
|
};
|
|
assert!(insert_email("a@x.io").await.is_ok());
|
|
assert!(
|
|
insert_email("a@x.io").await.is_err(),
|
|
"a duplicate value violates UNIQUE",
|
|
);
|
|
assert!(
|
|
insert_email("b@x.io").await.is_ok(),
|
|
"a distinct value is still accepted",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_default_applies_when_the_column_is_omitted() {
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![
|
|
col("id", Type::Serial),
|
|
col("name", Type::Text),
|
|
col_c("tier", Type::Int, false, false, Some(Value::Number("3".to_string()))),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Insert names only `name` — `tier` is omitted and so
|
|
// takes its DEFAULT; `id` auto-fills as a serial.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["name".to_string()]),
|
|
vec![Value::Text("Alice".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let data = db
|
|
.query_data("T".to_string(), None, None, None)
|
|
.await
|
|
.unwrap();
|
|
let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap();
|
|
assert_eq!(
|
|
data.rows[0][tier_idx].as_deref(),
|
|
Some("3"),
|
|
"the omitted column took its DEFAULT",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn describe_surfaces_the_column_constraints() {
|
|
let db = db();
|
|
let desc = table_with(
|
|
&db,
|
|
col_c(
|
|
"email",
|
|
Type::Text,
|
|
true,
|
|
true,
|
|
Some(Value::Text("none".to_string())),
|
|
),
|
|
)
|
|
.await;
|
|
let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
|
|
assert!(email.notnull, "NOT NULL is described");
|
|
assert!(email.unique, "UNIQUE is described");
|
|
assert_eq!(
|
|
email.default.as_deref(),
|
|
Some("'none'"),
|
|
"the DEFAULT literal is described",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_preserves_column_constraints() {
|
|
// A type change on one column rebuilds the whole table
|
|
// through `schema_to_ddl`; the constraints on the other
|
|
// columns must survive that round-trip.
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![
|
|
col("id", Type::Serial),
|
|
col_c("email", Type::Text, true, true, None),
|
|
col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Change `tier`'s type — int -> decimal — forcing a rebuild.
|
|
db.change_column_type(
|
|
"T".to_string(),
|
|
"tier".to_string(),
|
|
Type::Decimal,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
let email = desc.columns.iter().find(|c| c.name == "email").unwrap();
|
|
assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE");
|
|
let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
|
|
assert_eq!(
|
|
tier.default.as_deref(),
|
|
Some("1"),
|
|
"tier keeps its DEFAULT across the rebuild",
|
|
);
|
|
}
|
|
|
|
// --- column constraints at add-column (ADR-0029 §6) -----
|
|
|
|
#[tokio::test]
|
|
async fn add_column_with_default_fills_existing_rows() {
|
|
let db = db();
|
|
people_table(&db).await; // 4 rows
|
|
db.add_column(
|
|
"People".to_string(),
|
|
col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let data = db
|
|
.query_data("People".to_string(), None, None, None)
|
|
.await
|
|
.unwrap();
|
|
let idx = data.columns.iter().position(|c| c == "tier").unwrap();
|
|
assert!(
|
|
data.rows.iter().all(|r| r[idx].as_deref() == Some("1")),
|
|
"every existing row took the new column's DEFAULT",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_not_null_column_without_default_to_populated_table_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_column(
|
|
"People".to_string(),
|
|
col_c("x", Type::Int, true, false, None),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a NOT NULL column with no default cannot be added to a table with rows",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_not_null_column_without_default_to_empty_table_succeeds() {
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.add_column(
|
|
"T".to_string(),
|
|
col_c("x", Type::Int, true, false, None),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("NOT NULL with no default is fine on an empty table");
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_not_null_column_with_a_default_succeeds_on_a_populated_table() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_column(
|
|
"People".to_string(),
|
|
col_c("tier", Type::Int, true, false, Some(Value::Number("0".to_string()))),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db.describe_table("People".to_string(), None).await.unwrap();
|
|
let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap();
|
|
assert!(tier.notnull);
|
|
assert_eq!(tier.default.as_deref(), Some("0"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_unique_column_applies_the_constraint_via_rebuild() {
|
|
let db = db();
|
|
people_table(&db).await; // 4 rows; the new column is all-NULL
|
|
db.add_column(
|
|
"People".to_string(),
|
|
col_c("badge", Type::Text, false, true, None),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("a UNIQUE column with no default is fine — NULLs do not collide");
|
|
let desc = db.describe_table("People".to_string(), None).await.unwrap();
|
|
assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_unique_column_with_default_to_a_multi_row_table_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await; // 4 rows
|
|
let result = db
|
|
.add_column(
|
|
"People".to_string(),
|
|
col_c("badge", Type::Text, false, true, Some(Value::Text("X".to_string()))),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a UNIQUE column with a default would give every row the same value",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_serial_column_with_a_default_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_column(
|
|
"People".to_string(),
|
|
col_c("seq", Type::Serial, false, false, Some(Value::Number("1".to_string()))),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a serial column auto-fills its own values — a default is ambiguous",
|
|
);
|
|
}
|
|
|
|
// --- CHECK constraints (ADR-0029 §4) --------------------
|
|
|
|
/// Parse a `create table` DSL string into its db-call parts
|
|
/// — the way to get a real `Expr` into a `ColumnSpec.check`
|
|
/// without hand-building the AST.
|
|
fn parse_create(dsl: &str) -> (String, Vec<ColumnSpec>, Vec<String>) {
|
|
match crate::dsl::parser::parse_command(dsl).expect("create table parse") {
|
|
Command::CreateTable {
|
|
name,
|
|
columns,
|
|
primary_key,
|
|
} => (name, columns, primary_key),
|
|
other => panic!("expected CreateTable, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A `ColumnSpec` carrying a `CHECK`, parsed from DSL.
|
|
fn col_c_check(name: &str, ty: Type, check_dsl: &str) -> ColumnSpec {
|
|
let (_, columns, _) = parse_create(&format!(
|
|
"create table __probe with pk {name}({}) check ({check_dsl})",
|
|
ty.keyword(),
|
|
));
|
|
columns.into_iter().next().expect("one column")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_table_check_constraint_is_enforced() {
|
|
let db = db();
|
|
let (n, c, pk) = parse_create(
|
|
"create table Grades with pk grade(text) check (grade in ('A', 'B', 'C'))",
|
|
);
|
|
db.create_table(n, c, pk, None).await.unwrap();
|
|
let insert_grade = |g: &str| {
|
|
db.insert(
|
|
"Grades".to_string(),
|
|
Some(vec!["grade".to_string()]),
|
|
vec![Value::Text(g.to_string())],
|
|
None,
|
|
)
|
|
};
|
|
assert!(insert_grade("A").await.is_ok(), "a value the check allows");
|
|
assert!(
|
|
insert_grade("Z").await.is_err(),
|
|
"a value the check forbids is refused",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn describe_surfaces_the_check_constraint() {
|
|
let db = db();
|
|
let (n, c, pk) =
|
|
parse_create("create table T with pk age(int) check (age >= 0)");
|
|
db.create_table(n, c, pk, None).await.unwrap();
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
let age = desc.columns.iter().find(|c| c.name == "age").unwrap();
|
|
let check = age.check.as_deref().expect("age carries a CHECK");
|
|
assert!(
|
|
check.contains(">="),
|
|
"the compiled check SQL is surfaced: {check}",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_column_check_constraint_is_enforced() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_column(
|
|
"People".to_string(),
|
|
col_c_check("score", Type::Int, "score >= 0"),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("a CHECK column adds via the rebuild path");
|
|
let desc = db.describe_table("People".to_string(), None).await.unwrap();
|
|
assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some());
|
|
// An update that violates the check is refused.
|
|
let bad = db
|
|
.update(
|
|
"People".to_string(),
|
|
vec![("score".to_string(), Value::Number("-1".to_string()))],
|
|
parse_filter("update People set score=-1 where id = 1"),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(bad.is_err(), "an update violating the CHECK is refused");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_preserves_a_check_constraint() {
|
|
let db = db();
|
|
let (n, c, pk) =
|
|
parse_create("create table T with pk code(text) check (code like 'X%')");
|
|
db.create_table(n, c, pk, None).await.unwrap();
|
|
db.add_column("T".to_string(), col("note", Type::Int), None)
|
|
.await
|
|
.unwrap();
|
|
// A type change on `note` rebuilds the table; `code`'s
|
|
// CHECK must survive the round-trip through schema_to_ddl.
|
|
db.change_column_type(
|
|
"T".to_string(),
|
|
"note".to_string(),
|
|
Type::Decimal,
|
|
ChangeColumnMode::Default,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
assert!(
|
|
desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(),
|
|
"code keeps its CHECK across the rebuild",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_serial_column_with_a_check_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_column(
|
|
"People".to_string(),
|
|
col_c_check("seq", Type::Serial, "seq > 0"),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a CHECK on an auto-generated column is a create-table-only feature",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_with_all_rows_affects_everything() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
for name in ["Alice", "Bob", "Carol"] {
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
vec![Value::Text(name.to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.update(
|
|
"Customers".to_string(),
|
|
vec![("Name".to_string(), Value::Text("X".to_string()))],
|
|
RowFilter::AllRows,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 3);
|
|
assert_eq!(result.data.rows.len(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_with_where_removes_matching_rows() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
for name in ["Alice", "Bob"] {
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
vec![Value::Text(name.to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let result = db
|
|
.delete(
|
|
"Customers".to_string(),
|
|
RowFilter::eq("id", Value::Number("1".to_string())),
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1);
|
|
assert!(result.cascade.is_empty(), "no children to cascade to");
|
|
let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows.len(), 1);
|
|
assert_eq!(data.rows[0][1], Some("Bob".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fk_violation_returns_engine_classified_constraint_error() {
|
|
// Pre-H1 (ADR-0019), this test asserted that the
|
|
// engine's `FOREIGN KEY constraint failed` text was
|
|
// enriched in-band with a list of every relevant FK on
|
|
// the offending table. That enrichment moved into the
|
|
// friendly-error layer's catalog wording (see
|
|
// `friendly::translate::tests::fk_with_*_op_renders_*`)
|
|
// and out of the raw `DbError::Sqlite { message }`
|
|
// payload. What stays in `message` is the engine's
|
|
// un-enriched text — useful for the translator's own
|
|
// classification, but no longer the user-facing
|
|
// surface.
|
|
//
|
|
// This test now just confirms the engine error reaches
|
|
// us classified as a constraint violation; user-facing
|
|
// wording is exercised in the friendly module.
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial), col("CustId", Type::Int)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::NoAction,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = db
|
|
.insert(
|
|
"Orders".to_string(),
|
|
Some(vec!["CustId".to_string()]),
|
|
vec![Value::Number("999".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { message, .. } => {
|
|
assert!(
|
|
message.to_ascii_lowercase().contains("foreign key"),
|
|
"expected engine-classified FK message, got: {message}"
|
|
);
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cascade_delete_propagates_to_children() {
|
|
let db = db();
|
|
customers_table(&db).await;
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial), col("CustId", Type::Int)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
None,
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
ReferentialAction::Cascade,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
vec![Value::Text("Alice".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Orders".to_string(),
|
|
Some(vec!["CustId".to_string()]),
|
|
vec![Value::Number("1".to_string())],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
// Delete Alice — cascades to Orders.
|
|
db.delete(
|
|
"Customers".to_string(),
|
|
RowFilter::eq("id", Value::Number("1".to_string())),
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap();
|
|
assert!(orders.rows.is_empty(), "child rows should be cascaded");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn self_referential_cascade_counts_only_cascaded_rows() {
|
|
// A self-referential ON DELETE CASCADE FK (T.ParentId -> T.id):
|
|
// deleting the root of a chain cascades down within T. The
|
|
// directly-deleted root is reported in `rows_affected`, so the
|
|
// cascade summary must report only the *additional* rows
|
|
// removed by the self-reference — the raw before/after diff
|
|
// would double-count the direct delete. Parity fix shared with
|
|
// `do_sql_delete` (ADR-0033 Amendment 2 mechanism).
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Int), col("ParentId", Type::Int)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
Some("parent_of".to_string()),
|
|
"T".to_string(),
|
|
"id".to_string(),
|
|
"T".to_string(),
|
|
"ParentId".to_string(),
|
|
ReferentialAction::Cascade,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Chain: 1 (root) <- 2 <- 3.
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "ParentId".to_string()]),
|
|
vec![Value::Number("1".to_string()), Value::Null],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "ParentId".to_string()]),
|
|
vec![Value::Number("2".to_string()), Value::Number("1".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["id".to_string(), "ParentId".to_string()]),
|
|
vec![Value::Number("3".to_string()), Value::Number("2".to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let result = db
|
|
.delete(
|
|
"T".to_string(),
|
|
RowFilter::eq("id", Value::Number("1".to_string())),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.rows_affected, 1, "one row matched the filter directly");
|
|
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once");
|
|
assert_eq!(
|
|
result.cascade[0].rows_changed, 2,
|
|
"only the 2 cascaded rows, not the directly-deleted root too"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_data_renders_bools_as_words() {
|
|
let db = db();
|
|
db.create_table(
|
|
"Flags".to_string(),
|
|
vec![col("id", Type::Serial), col("Active", Type::Bool)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Flags".to_string(),
|
|
None,
|
|
vec![Value::Bool(true)],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Flags".to_string(),
|
|
None,
|
|
vec![Value::Bool(false)],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows[0][1], Some("true".to_string()));
|
|
assert_eq!(data.rows[1][1], Some("false".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn query_data_renders_null_as_none() {
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial), col("note", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"T".to_string(),
|
|
None,
|
|
vec![Value::Null],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let data = db.query_data("T".to_string(), None, None, None).await.unwrap();
|
|
assert_eq!(data.rows[0][1], None);
|
|
}
|
|
|
|
// ---- ADR-0018 UNIQUE infrastructure ----
|
|
|
|
#[test]
|
|
fn read_schema_detects_single_column_unique() {
|
|
// Create a raw connection (no Database wrapper) so we
|
|
// can drop arbitrary DDL containing UNIQUE — the DSL
|
|
// doesn't yet expose UNIQUE as a user-controlled
|
|
// constraint (C3-track).
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
configure_connection(&conn).unwrap();
|
|
conn.execute_batch(
|
|
"CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;",
|
|
)
|
|
.unwrap();
|
|
let schema = read_schema(&conn, "T").unwrap();
|
|
let id = schema.columns.iter().find(|c| c.name == "id").unwrap();
|
|
let code = schema.columns.iter().find(|c| c.name == "code").unwrap();
|
|
assert!(id.primary_key, "id should still be PK");
|
|
assert!(
|
|
!id.unique,
|
|
"PK columns are not separately marked unique \
|
|
(PK already implies it; double-marking would lead to \
|
|
redundant DDL)"
|
|
);
|
|
assert!(code.unique, "code should be detected as unique");
|
|
}
|
|
|
|
#[test]
|
|
fn read_schema_ignores_compound_unique_constraints() {
|
|
// Multi-column UNIQUE is out of scope for ADR-0018; the
|
|
// detection helper filters it out so schema_to_ddl does
|
|
// not accidentally emit a column-level UNIQUE for one
|
|
// half of a compound constraint.
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
configure_connection(&conn).unwrap();
|
|
conn.execute_batch(
|
|
"CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT, UNIQUE(a, b)) STRICT;",
|
|
)
|
|
.unwrap();
|
|
let schema = read_schema(&conn, "T").unwrap();
|
|
for col in &schema.columns {
|
|
assert!(
|
|
!col.unique,
|
|
"column {} should not be marked unique under compound UNIQUE",
|
|
col.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn schema_to_ddl_emits_unique_for_marked_column() {
|
|
// Construct a ReadSchema with a unique non-PK column and
|
|
// confirm the round-trip through schema_to_ddl produces
|
|
// valid DDL containing UNIQUE.
|
|
let schema = ReadSchema {
|
|
columns: vec![
|
|
ReadColumn {
|
|
name: "id".to_string(),
|
|
sqlite_type: "INTEGER".to_string(),
|
|
notnull: false,
|
|
primary_key: true,
|
|
unique: false,
|
|
default_sql: None,
|
|
check: None,
|
|
user_type: Some(Type::Serial),
|
|
},
|
|
ReadColumn {
|
|
name: "code".to_string(),
|
|
sqlite_type: "TEXT".to_string(),
|
|
notnull: false,
|
|
primary_key: false,
|
|
unique: true,
|
|
default_sql: None,
|
|
check: None,
|
|
user_type: Some(Type::Text),
|
|
},
|
|
],
|
|
primary_key: vec!["id".to_string()],
|
|
foreign_keys: vec![],
|
|
};
|
|
let ddl = schema_to_ddl("T", &schema);
|
|
assert!(
|
|
ddl.contains("\"id\" INTEGER PRIMARY KEY"),
|
|
"PK emission preserved: {ddl}"
|
|
);
|
|
assert!(
|
|
ddl.contains("\"code\" TEXT UNIQUE"),
|
|
"UNIQUE emitted on non-PK column: {ddl}"
|
|
);
|
|
assert!(
|
|
!ddl.contains("\"id\" INTEGER PRIMARY KEY UNIQUE"),
|
|
"PK column should not double-emit UNIQUE: {ddl}"
|
|
);
|
|
// The DDL is valid SQL — apply it and read it back.
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
configure_connection(&conn).unwrap();
|
|
conn.execute_batch(&ddl).unwrap();
|
|
let round = read_schema(&conn, "T").unwrap();
|
|
let code = round.columns.iter().find(|c| c.name == "code").unwrap();
|
|
assert!(code.unique, "round-tripped column retains unique flag");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unique_flag_persisted_in_yaml_for_non_pk_serial() {
|
|
// ADR-0018 §4: a non-PK serial column gains UNIQUE,
|
|
// and the flag must be recorded in `project.yaml` so
|
|
// that a rebuild-from-text reconstructs the constraint
|
|
// faithfully (preserves the "serial -> int leaves
|
|
// UNIQUE in place" promise across save/load cycles).
|
|
use crate::persistence::Persistence;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let persistence = Persistence::new(dir.path().to_path_buf());
|
|
let db_path = dir.path().join("playground.db");
|
|
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial), col("Name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
// Read the persisted YAML straight from disk.
|
|
let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
|
|
assert!(
|
|
yaml.contains("seq") && yaml.contains("unique: true"),
|
|
"yaml should record the unique flag for `seq`: {yaml}"
|
|
);
|
|
// PK column not separately marked unique — PK already
|
|
// implies UNIQUE, double-marking would emit redundant
|
|
// DDL on rebuild.
|
|
let lines_with_id: Vec<&str> = yaml
|
|
.lines()
|
|
.filter(|l| l.contains("name: id"))
|
|
.collect();
|
|
for line in &lines_with_id {
|
|
assert!(
|
|
!line.contains("unique: true"),
|
|
"PK column should not be marked unique in YAML: {line}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unique_flag_round_trips_through_rebuild() {
|
|
// End-to-end: create a table with a non-PK serial,
|
|
// then rebuild from text. The rebuild should re-emit
|
|
// UNIQUE on the column, restoring the constraint
|
|
// exactly as the original.
|
|
use crate::persistence::Persistence;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let persistence = Persistence::new(dir.path().to_path_buf());
|
|
let db_path = dir.path().join("playground.db");
|
|
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("id", Type::Serial), col("Name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None)
|
|
.await
|
|
.unwrap();
|
|
// Tear down the .db file and rebuild from yaml + csvs.
|
|
drop(db);
|
|
std::fs::remove_file(&db_path).unwrap();
|
|
let persistence2 = Persistence::new(dir.path().to_path_buf());
|
|
let db = Database::open_with_persistence(&db_path, persistence2).unwrap();
|
|
db.rebuild_from_text(dir.path().to_path_buf(), None)
|
|
.await
|
|
.unwrap();
|
|
// Read the schema and confirm `seq` is still unique.
|
|
let desc = db.describe_table("T".to_string(), None).await.unwrap();
|
|
let seq = desc
|
|
.columns
|
|
.iter()
|
|
.find(|c| c.name == "seq")
|
|
.expect("seq column rebuilt");
|
|
assert_eq!(seq.user_type, Some(Type::Serial));
|
|
// Confirm the UNIQUE constraint is enforced post-
|
|
// rebuild by attempting to UPDATE all rows to the same
|
|
// value... but the table is empty. Instead, insert two
|
|
// rows and then UPDATE.
|
|
for n in ["a", "b"] {
|
|
db.insert(
|
|
"T".to_string(),
|
|
Some(vec!["Name".to_string()]),
|
|
vec![Value::Text(n.to_string())],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let err = db
|
|
.update(
|
|
"T".to_string(),
|
|
vec![("seq".to_string(), Value::Number("1".to_string()))],
|
|
RowFilter::AllRows,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
match err {
|
|
DbError::Sqlite { kind, .. } => {
|
|
assert_eq!(kind, SqliteErrorKind::UniqueViolation);
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn quoted_table_names_round_trip() {
|
|
let db = db();
|
|
// Identifier with internal whitespace would not parse via the DSL
|
|
// today, but the DB layer should still handle it correctly.
|
|
db.create_table(
|
|
"Order Lines".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None)
|
|
.await
|
|
.unwrap();
|
|
let tables = db.list_tables().await.unwrap();
|
|
assert_eq!(tables, vec!["Order Lines".to_string()]);
|
|
let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap();
|
|
assert_eq!(desc.name, "Order Lines");
|
|
}
|
|
|
|
// ---- list_names_for (ADR-0022 §9, stage 7) ----
|
|
|
|
#[tokio::test]
|
|
async fn list_names_for_new_name_short_circuits_to_empty() {
|
|
// The user invents new names; schema has nothing to
|
|
// offer. The public method short-circuits before
|
|
// touching the worker.
|
|
let db = db();
|
|
let names = db
|
|
.list_names_for(crate::dsl::grammar::IdentSource::NewName)
|
|
.await
|
|
.unwrap();
|
|
assert!(names.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_names_for_table_returns_user_tables_alphabetised() {
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
make_id_table(&db, "Orders").await;
|
|
let names = db
|
|
.list_names_for(crate::dsl::grammar::IdentSource::Tables)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names, vec!["Customers".to_string(), "Orders".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_names_for_table_filters_internal_metadata_tables() {
|
|
// The internal __rdbms_* tables must never appear in
|
|
// the completion menu (ADR-0002 user-facing posture).
|
|
let db = db();
|
|
make_id_table(&db, "Customers").await;
|
|
let names = db
|
|
.list_names_for(crate::dsl::grammar::IdentSource::Tables)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names, vec!["Customers".to_string()]);
|
|
for n in &names {
|
|
assert!(
|
|
!n.starts_with("__rdbms_"),
|
|
"internal table leaked into completion: {n}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_names_for_column_returns_distinct_columns_alphabetised() {
|
|
let db = db();
|
|
// Two tables sharing a column name `id`, with different
|
|
// additional columns.
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial), col("name", Type::Text)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial), col("total", Type::Real)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let names = db
|
|
.list_names_for(crate::dsl::grammar::IdentSource::Columns)
|
|
.await
|
|
.unwrap();
|
|
// `id` appears once despite being in both tables (DISTINCT).
|
|
assert_eq!(
|
|
names,
|
|
vec!["id".to_string(), "name".to_string(), "total".to_string()],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_names_for_relationship_returns_named_relationships() {
|
|
let db = db();
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![col("id", Type::Serial)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![col("id", Type::Serial), col("CustId", Type::Int)],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.add_relationship(
|
|
Some("cust_orders".to_string()),
|
|
"Customers".to_string(),
|
|
"id".to_string(),
|
|
"Orders".to_string(),
|
|
"CustId".to_string(),
|
|
crate::dsl::ReferentialAction::NoAction,
|
|
crate::dsl::ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let names = db
|
|
.list_names_for(crate::dsl::grammar::IdentSource::Relationships)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names, vec!["cust_orders".to_string()]);
|
|
}
|
|
|
|
// --- add constraint / drop constraint (ADR-0029 §2.2) -----
|
|
|
|
/// A `Constraint::Check` whose expression references
|
|
/// `column` of type `ty`, parsed from `check_dsl`.
|
|
fn check_constraint(column: &str, ty: Type, check_dsl: &str) -> Constraint {
|
|
Constraint::Check(
|
|
col_c_check(column, ty, check_dsl)
|
|
.check
|
|
.expect("the CHECK expression parses"),
|
|
)
|
|
}
|
|
|
|
/// `People` plus an all-NULL plain `int` column `x`.
|
|
async fn people_with_null_column(db: &Database) {
|
|
people_table(db).await;
|
|
db.add_column("People".to_string(), col("x", Type::Int), None)
|
|
.await
|
|
.expect("a plain nullable column adds");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_not_null_succeeds_on_clean_column() {
|
|
let db = db();
|
|
people_table(&db).await; // every row has a non-null Name
|
|
let desc = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"Name".to_string(),
|
|
Constraint::NotNull,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("NOT NULL applies — no row holds a null");
|
|
assert!(
|
|
desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull,
|
|
"the column is now NOT NULL",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_not_null_refused_when_rows_are_null() {
|
|
let db = db();
|
|
people_with_null_column(&db).await; // `x` is null in every row
|
|
let result = db
|
|
.add_constraint("People".to_string(), "x".to_string(), Constraint::NotNull, None)
|
|
.await;
|
|
let err = result.expect_err("NOT NULL is refused — rows hold null");
|
|
assert!(
|
|
format!("{err}").contains("null"),
|
|
"the refusal explains the null rows: {err}",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_unique_succeeds_on_distinct_values() {
|
|
let db = db();
|
|
people_table(&db).await; // Names are all distinct
|
|
let desc = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"Name".to_string(),
|
|
Constraint::Unique,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("UNIQUE applies — every Name is distinct");
|
|
assert!(desc.columns.iter().find(|c| c.name == "Name").unwrap().unique);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_unique_refused_on_duplicate_values() {
|
|
let db = db();
|
|
people_table(&db).await; // Age 35 appears for Bob and Dave
|
|
let result = db
|
|
.add_constraint("People".to_string(), "Age".to_string(), Constraint::Unique, None)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"UNIQUE is refused — the value 35 appears in two rows",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_check_succeeds_when_data_satisfies_it() {
|
|
let db = db();
|
|
people_table(&db).await; // every Age is >= 0
|
|
let desc = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
check_constraint("Age", Type::Int, "Age >= 0"),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("the CHECK applies — all rows satisfy it");
|
|
assert!(desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_check_refused_when_rows_violate_it() {
|
|
let db = db();
|
|
people_table(&db).await; // Alice is 25, Bob/Dave are 35
|
|
let result = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
check_constraint("Age", Type::Int, "Age >= 40"),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(result.is_err(), "the CHECK is refused — three rows are under 40");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_default_succeeds() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let desc = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
Constraint::Default(Value::Number("0".to_string())),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("a DEFAULT applies — it never touches existing rows");
|
|
assert_eq!(
|
|
desc.columns.iter().find(|c| c.name == "Age").unwrap().default.as_deref(),
|
|
Some("0"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_check_is_enforced_after_being_added() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
check_constraint("Age", Type::Int, "Age >= 0"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let bad = db
|
|
.insert(
|
|
"People".to_string(),
|
|
None,
|
|
vec![
|
|
Value::Text("Eve".to_string()),
|
|
Value::Number("-1".to_string()),
|
|
Value::Bool(true),
|
|
],
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(bad.is_err(), "an insert violating the new CHECK is refused");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_not_null_on_pk_column_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_constraint("People".to_string(), "id".to_string(), Constraint::NotNull, None)
|
|
.await;
|
|
assert!(result.is_err(), "a PK column is already NOT NULL (ADR-0029 §9)");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_unique_on_single_column_pk_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_constraint("People".to_string(), "id".to_string(), Constraint::Unique, None)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a single-column PK is already UNIQUE (ADR-0029 §9)",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_unique_on_compound_pk_member_is_allowed() {
|
|
// A compound PK does not make its members individually
|
|
// unique, so `unique` on one of them is meaningful.
|
|
let db = db();
|
|
db.create_table(
|
|
"T".to_string(),
|
|
vec![col("a", Type::Int), col("b", Type::Int)],
|
|
vec!["a".to_string(), "b".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.add_constraint("T".to_string(), "a".to_string(), Constraint::Unique, None)
|
|
.await
|
|
.expect("UNIQUE on a compound-PK member is allowed");
|
|
assert!(desc.columns.iter().find(|c| c.name == "a").unwrap().unique);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_default_on_serial_column_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_constraint(
|
|
"People".to_string(),
|
|
"id".to_string(),
|
|
Constraint::Default(Value::Number("5".to_string())),
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"a serial column auto-fills its own values (ADR-0029 §6)",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_constraint_to_missing_column_errors() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.add_constraint("People".to_string(), "ghost".to_string(), Constraint::Unique, None)
|
|
.await;
|
|
assert!(result.is_err(), "no such column");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_constraint_removes_not_null() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_constraint(
|
|
"People".to_string(),
|
|
"Name".to_string(),
|
|
Constraint::NotNull,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.drop_constraint(
|
|
"People".to_string(),
|
|
"Name".to_string(),
|
|
ConstraintKind::NotNull,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("the NOT NULL is dropped");
|
|
assert!(
|
|
!desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull,
|
|
"the column is nullable again",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_constraint_check_clears_the_constraint() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
db.add_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
check_constraint("Age", Type::Int, "Age >= 0"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let desc = db
|
|
.drop_constraint(
|
|
"People".to_string(),
|
|
"Age".to_string(),
|
|
ConstraintKind::Check,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("the CHECK is dropped");
|
|
assert!(
|
|
desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_none(),
|
|
"the CHECK is gone from the structure view",
|
|
);
|
|
// With the CHECK gone, a previously-forbidden value inserts.
|
|
db.insert(
|
|
"People".to_string(),
|
|
None,
|
|
vec![
|
|
Value::Text("Eve".to_string()),
|
|
Value::Number("-1".to_string()),
|
|
Value::Bool(true),
|
|
],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("the CHECK no longer applies");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_constraint_not_null_on_pk_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await;
|
|
let result = db
|
|
.drop_constraint(
|
|
"People".to_string(),
|
|
"id".to_string(),
|
|
ConstraintKind::NotNull,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"the PK still enforces NOT NULL — nothing to drop (ADR-0029 §9)",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drop_constraint_absent_constraint_is_refused() {
|
|
let db = db();
|
|
people_table(&db).await; // `Name` carries no UNIQUE
|
|
let result = db
|
|
.drop_constraint(
|
|
"People".to_string(),
|
|
"Name".to_string(),
|
|
ConstraintKind::Unique,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"dropping a constraint the column never had is a friendly refusal",
|
|
);
|
|
}
|
|
}
|