DSL parser, async DB worker, types, history, metadata, polish
Track 1 implementation plus polish round. Parser (chumsky): - Grammar-based DSL producing a typed Command AST. - create table X with pk [name:type[,name:type...]] supports arbitrary names, any user type, compound PKs natively. Bare form errors with a friendly hint pointing at `with pk`. - add column to table X: Name (type); drop table X. - Required clauses use keyword grammar; -- reserved for opt-in flags (ADR-0009). Custom Rich reasons preferred when surfacing chumsky errors so unknown-type messages list valid alternatives. Database (ADR-0010, ADR-0012): - rusqlite + STRICT tables + foreign_keys=ON. - Dedicated worker thread; mpsc Request inbox, oneshot replies. - Typed DbError with friendly_message() hook for H1. - Internal __rdbms_playground_columns metadata table preserves user-facing types across schema reads, atomically maintained alongside DDL via Connection transactions. list_tables hides it via the new __rdbms_ internal-table convention. Types (ADR-0005, ADR-0011): - All ten user-facing types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid. - Type::fk_target_type() for FK-side column-type rule (Serial->Int, ShortId->Text, others identity) -- foundation for the FK iteration. App / Runtime / UI: - update() stays pure-sync; runtime dispatches DSL via spawned tasks, results post back as AppEvent::Dsl*. - Items panel renders live tables list; output panel shows the user-facing structure of the current table after each DDL. - In-memory command history (Up/Down, draft preservation, consecutive-duplicate dedup) -- I2 partial. - Mouse capture removed; terminal native text selection restored (toggle approach revisited when scroll/click features land). Docs: - ADRs 0009 (DSL syntax conventions), 0010 (DB worker), 0011 (FK type compat), 0012 (internal metadata table). - requirements.md progress notes; new V4 entry for the scrollable session-log + inline rich rendering + Markdown export direction. Tests: 103 passing (91 lib + 12 integration), 0 skipped. Clippy clean with nursery enabled.
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
//! 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::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::dsl::ColumnSpec;
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColumnDescription {
|
||||
pub name: String,
|
||||
/// The user-facing type the column was declared as, recovered
|
||||
/// from our internal column-metadata table. Always populated
|
||||
/// for tables created through the DSL. `None` only for the
|
||||
/// edge case of a foreign-attached database whose tables we
|
||||
/// did not create — not achievable in the current flow.
|
||||
pub user_type: Option<Type>,
|
||||
/// The SQLite-side type as reported by `PRAGMA table_info`
|
||||
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
|
||||
/// fall-back when `user_type` is not available; the UI
|
||||
/// prefers `user_type` when rendering.
|
||||
pub sqlite_type: String,
|
||||
pub notnull: bool,
|
||||
pub primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("database error: {message}")]
|
||||
Sqlite { message: String, kind: SqliteErrorKind },
|
||||
#[error("operation not supported: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("database worker is no longer available")]
|
||||
WorkerGone,
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
#[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 {
|
||||
/// Placeholder for the H1 friendly-error layer. Today this
|
||||
/// returns the same string as [`std::fmt::Display`]; when H1
|
||||
/// lands the body becomes the translation logic and
|
||||
/// callsites do not need to change.
|
||||
#[must_use]
|
||||
pub fn friendly_message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
fn from_rusqlite(err: rusqlite::Error) -> Self {
|
||||
let message = err.to_string();
|
||||
let kind = classify_sqlite_error(&err, &message);
|
||||
Self::Sqlite { message, kind }
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
DropTable {
|
||||
name: String,
|
||||
reply: oneshot::Sender<Result<(), DbError>>,
|
||||
},
|
||||
AddColumn {
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
ListTables {
|
||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
||||
},
|
||||
DescribeTable {
|
||||
name: String,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open a database. The path may be a filesystem location
|
||||
/// or `":memory:"` for an ephemeral in-memory database. The
|
||||
/// connection is moved onto a dedicated worker thread.
|
||||
pub fn open<P: AsRef<Path> + Into<String>>(path: P) -> 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, 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>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn drop_table(&self, name: String) -> Result<(), DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::DropTable { name, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn add_column(
|
||||
&self,
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AddColumn {
|
||||
table,
|
||||
column,
|
||||
ty,
|
||||
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) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::DescribeTable { name, 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";
|
||||
|
||||
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys = ON;\n\
|
||||
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
|
||||
table_name TEXT NOT NULL,\n\
|
||||
column_name TEXT NOT NULL,\n\
|
||||
user_type TEXT NOT NULL,\n\
|
||||
PRIMARY KEY (table_name, column_name)\n\
|
||||
) STRICT;"
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_loop(conn: Connection, mut rx: mpsc::Receiver<Request>) {
|
||||
debug!("db worker started");
|
||||
while let Some(req) = rx.blocking_recv() {
|
||||
handle_request(&conn, req);
|
||||
}
|
||||
debug!("db worker exiting");
|
||||
}
|
||||
|
||||
fn handle_request(conn: &Connection, req: Request) {
|
||||
match req {
|
||||
Request::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(do_create_table(conn, &name, &columns, &primary_key));
|
||||
}
|
||||
Request::DropTable { name, reply } => {
|
||||
let _ = reply.send(do_drop_table(conn, &name));
|
||||
}
|
||||
Request::AddColumn {
|
||||
table,
|
||||
column,
|
||||
ty,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(do_add_column(conn, &table, &column, ty));
|
||||
}
|
||||
Request::ListTables { reply } => {
|
||||
let _ = reply.send(do_list_tables(conn));
|
||||
}
|
||||
Request::DescribeTable { name, reply } => {
|
||||
let _ = reply.send(do_describe_table(conn, &name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote an identifier for safe inclusion in DDL. Doubles any
|
||||
/// embedded double-quotes per SQL convention.
|
||||
fn quote_ident(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len() + 2);
|
||||
out.push('"');
|
||||
for c in name.chars() {
|
||||
if c == '"' {
|
||||
out.push_str("\"\"");
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn do_create_table(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
columns: &[ColumnSpec],
|
||||
primary_key: &[String],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
// already prevents this, but defending here too keeps
|
||||
// the executor honest if anyone synthesises a Command
|
||||
// directly (tests, future scripting).
|
||||
return Err(DbError::Unsupported(
|
||||
"tables need at least one column".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate the column list. For a single-column PK we inline
|
||||
// `PRIMARY KEY` on the column itself, which is required for
|
||||
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
|
||||
// column its rowid-alias semantics. For compound PKs (or
|
||||
// when the single PK is on a non-first column) we emit a
|
||||
// table-level constraint.
|
||||
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
|
||||
&& primary_key[0] == columns[0].name;
|
||||
|
||||
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
|
||||
for col in columns {
|
||||
let mut clause = format!(
|
||||
"{ident} {sqlite_type}",
|
||||
ident = quote_ident(&col.name),
|
||||
sqlite_type = col.ty.sqlite_strict_type(),
|
||||
);
|
||||
if single_inline_pk {
|
||||
clause.push_str(" PRIMARY KEY");
|
||||
}
|
||||
column_clauses.push(clause);
|
||||
}
|
||||
|
||||
let mut ddl = format!(
|
||||
"CREATE TABLE {ident} ({columns}",
|
||||
ident = quote_ident(name),
|
||||
columns = column_clauses.join(", "),
|
||||
);
|
||||
|
||||
if !single_inline_pk && !primary_key.is_empty() {
|
||||
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
|
||||
ddl.push_str(", PRIMARY KEY (");
|
||||
ddl.push_str(&pk_idents.join(", "));
|
||||
ddl.push(')');
|
||||
}
|
||||
|
||||
ddl.push_str(") STRICT;");
|
||||
debug!(ddl = %ddl, "create_table");
|
||||
|
||||
// Wrap the table-creation DDL and the metadata inserts in a
|
||||
// single transaction so they commit atomically — if either
|
||||
// step fails, neither side persists.
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare(&format!(
|
||||
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
||||
VALUES (?1, ?2, ?3);"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
for col in columns {
|
||||
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
do_describe_table(conn, name)
|
||||
}
|
||||
|
||||
fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> {
|
||||
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)?;
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn do_add_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
ty: Type,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if ty == Type::Serial {
|
||||
return Err(DbError::Unsupported(
|
||||
"the 'serial' type carries auto-increment primary-key semantics \
|
||||
that SQLite's ALTER TABLE ADD COLUMN cannot apply. Specify \
|
||||
`serial` at create-table time via `with pk` instead."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let mut ddl = String::new();
|
||||
write!(
|
||||
ddl,
|
||||
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{extra};",
|
||||
tbl = quote_ident(table),
|
||||
col = quote_ident(column),
|
||||
sqlite_type = ty.sqlite_strict_type(),
|
||||
extra = ty.sqlite_strict_extra(),
|
||||
)
|
||||
.expect("write to String never fails");
|
||||
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)?;
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
do_describe_table(conn, table)
|
||||
}
|
||||
|
||||
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 name NOT LIKE '__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)
|
||||
}
|
||||
|
||||
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
||||
// `pragma_table_info` is a table-valued function in modern
|
||||
// SQLite; using it as a SELECT lets us bind the table name
|
||||
// via ? rather than splicing it into a PRAGMA statement.
|
||||
// We LEFT JOIN our metadata table to recover the user-facing
|
||||
// type each column was declared as.
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \
|
||||
FROM pragma_table_info(?1) AS pti \
|
||||
LEFT JOIN {META_TABLE} AS m \
|
||||
ON m.table_name = ?1 AND m.column_name = pti.name \
|
||||
ORDER BY pti.cid;"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let rows = stmt
|
||||
.query_map([name], |row| {
|
||||
let user_type_kw: Option<String> = row.get(4)?;
|
||||
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
|
||||
Ok(ColumnDescription {
|
||||
name: row.get(0)?,
|
||||
user_type,
|
||||
sqlite_type: row.get(1)?,
|
||||
notnull: row.get::<_, i64>(2)? != 0,
|
||||
primary_key: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut columns = Vec::new();
|
||||
for row in rows {
|
||||
columns.push(row.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
if columns.is_empty() {
|
||||
// pragma_table_info returns no rows for a non-existent
|
||||
// table, which we surface as a NoSuchTable error so
|
||||
// describe_table is not silently empty.
|
||||
warn!(name, "describe_table: no columns (table missing?)");
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such table: {name}"),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
});
|
||||
}
|
||||
Ok(TableDescription {
|
||||
name: name.to_string(),
|
||||
columns,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn db() -> Database {
|
||||
Database::open(":memory:").expect("open in-memory")
|
||||
}
|
||||
|
||||
fn col(name: &str, ty: Type) -> ColumnSpec {
|
||||
ColumnSpec {
|
||||
name: name.to_string(),
|
||||
ty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: a `serial`-PK table with a single `id` column.
|
||||
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![col("id", Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
)
|
||||
.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()],
|
||||
)
|
||||
.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()],
|
||||
)
|
||||
.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()],
|
||||
)
|
||||
.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())
|
||||
.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()).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 desc = db
|
||||
.add_column("Customers".to_string(), "Name".to_string(), Type::Text)
|
||||
.await
|
||||
.unwrap();
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_facing_types_round_trip_through_metadata() {
|
||||
let db = db();
|
||||
// Create with a serial PK and add columns of every type
|
||||
// that would otherwise be erased by SQLite (date,
|
||||
// datetime, decimal — all backed by TEXT).
|
||||
make_id_table(&db, "T").await;
|
||||
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
|
||||
db.add_column("T".to_string(), format!("c_{ty}"), ty)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let desc = db.describe_table("T".to_string()).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()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = db.describe_table("T".to_string()).await.unwrap();
|
||||
assert_eq!(before.columns[0].user_type, Some(Type::Date));
|
||||
|
||||
// Drop it.
|
||||
db.drop_table("T".to_string()).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()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = db.describe_table("T".to_string()).await.unwrap();
|
||||
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_for_each_value_type() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
|
||||
let col_name = format!("c_{ty}");
|
||||
db.add_column("T".to_string(), col_name.clone(), ty)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
|
||||
}
|
||||
let desc = db.describe_table("T".to_string()).await.unwrap();
|
||||
// 5 user columns + the id PK column.
|
||||
assert_eq!(desc.columns.len(), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_rejects_serial_with_unsupported_error() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
let err = db
|
||||
.add_column("T".to_string(), "id2".to_string(), Type::Serial)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[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()],
|
||||
)
|
||||
.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()).await.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_to_missing_table_returns_no_such_table() {
|
||||
let db = db();
|
||||
let err = db
|
||||
.add_column("Ghost".to_string(), "x".to_string(), Type::Text)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_missing_table_returns_no_such_table() {
|
||||
let db = db();
|
||||
let err = db.describe_table("Ghost".to_string()).await.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {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()],
|
||||
)
|
||||
.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()).await.unwrap();
|
||||
assert_eq!(desc.name, "Order Lines");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user