#![forbid(unsafe_code)]
mod drift;
pub use drift::{UNSEEN, compare, drift, unseen};
use cratefield_core::{Database, DbError, Row, Rows, Statement};
use cratefield_tables::{FieldDef, FieldKind, ForeignKey, Schema, TableDef};
fn harness_exclusion(whole_catalog: bool, column: &str) -> String {
if whole_catalog {
String::new()
} else {
format!("AND {column} NOT LIKE 'harness\\_%' ESCAPE '\\'")
}
}
pub async fn schema(db: &dyn Database) -> Result<Schema, DbError> {
read_schema(db, false).await
}
pub async fn full_schema(db: &dyn Database) -> Result<Schema, DbError> {
read_schema(db, true).await
}
async fn read_schema(db: &dyn Database, whole_catalog: bool) -> Result<Schema, DbError> {
match sqlite_tables(db, whole_catalog).await {
Ok(tables) => sqlite_schema(db, tables).await,
Err(sqlite_probe) => match postgres_schema(db, whole_catalog).await {
Ok(schema) => Ok(schema),
Err(postgres_probe) => Err(DbError::Query(format!(
"could not read a schema from either catalog: sqlite said \
[{sqlite_probe}]; postgres said [{postgres_probe}]"
))),
},
}
}
pub async fn row_count(db: &dyn Database, table: &str) -> Result<u64, DbError> {
let rows = db
.query(&Statement::new(format!(
"SELECT COUNT(*) AS n FROM {}",
quote_ident(table)
)))
.await?;
let count = rows
.first()
.and_then(|row| row.get::<i64>("n"))
.unwrap_or(0);
Ok(u64::try_from(count).unwrap_or(0))
}
pub async fn rows(
db: &dyn Database,
table: &str,
order_by: &[&str],
limit: u64,
offset: u64,
) -> Result<Rows, DbError> {
let order = order_by
.iter()
.map(|column| quote_ident(column))
.collect::<Vec<_>>()
.join(", ");
let sql = if order.is_empty() {
format!("SELECT * FROM {} LIMIT ? OFFSET ?", quote_ident(table))
} else {
format!(
"SELECT * FROM {} ORDER BY {order} LIMIT ? OFFSET ?",
quote_ident(table)
)
};
db.query(&Statement::with_values(
sql,
vec![
sea_query::Value::BigInt(Some(i64::try_from(limit).unwrap_or(i64::MAX))),
sea_query::Value::BigInt(Some(i64::try_from(offset).unwrap_or(0))),
],
))
.await
}
async fn sqlite_tables(db: &dyn Database, whole_catalog: bool) -> Result<Vec<String>, DbError> {
let rows = db
.query(&Statement::new(format!(
"SELECT name FROM sqlite_master \
WHERE type = 'table' \
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' \
{harness} \
ORDER BY name ASC",
harness = harness_exclusion(whole_catalog, "name")
)))
.await?;
Ok(rows
.rows
.iter()
.filter_map(|row| row.get::<String>("name"))
.collect())
}
async fn sqlite_schema(db: &dyn Database, names: Vec<String>) -> Result<Schema, DbError> {
let mut tables = Vec::with_capacity(names.len());
for name in names {
let columns = db
.query(&Statement::with_values(
"SELECT name AS column_name, type AS declared, \"notnull\" AS not_null, \
pk AS pk_pos FROM pragma_table_info(?) ORDER BY cid",
vec![text(&name)],
))
.await?;
let foreign_keys = db
.query(&Statement::with_values(
"SELECT \"table\" AS parent, \"from\" AS child_column \
FROM pragma_foreign_key_list(?) ORDER BY id, seq",
vec![text(&name)],
))
.await?;
let indexes = db
.query(&Statement::with_values(
"SELECT name AS index_name, \"unique\" AS is_unique, origin AS origin \
FROM pragma_index_list(?) ORDER BY seq",
vec![text(&name)],
))
.await?;
let mut unique: Vec<String> = Vec::new();
let mut indexed_columns: Vec<String> = Vec::new();
for index in &indexes.rows {
let Some(index_name) = index.get::<String>("index_name") else {
continue;
};
let is_unique = index.get::<i64>("is_unique").unwrap_or(0) == 1;
let origin = index.get::<String>("origin").unwrap_or_default();
let members = db
.query(&Statement::with_values(
"SELECT name AS column_name FROM pragma_index_info(?) ORDER BY seqno",
vec![text(&index_name)],
))
.await?
.rows
.iter()
.filter_map(|row| row.get::<String>("column_name"))
.collect::<Vec<_>>();
if members.len() != 1 || origin == "pk" {
continue;
}
if is_unique {
unique.push(members[0].clone());
} else if origin == "c" {
indexed_columns.push(members[0].clone());
}
}
tables.push(sqlite_table(
&name,
&columns.rows,
&foreign_keys.rows,
&unique,
&indexed_columns,
));
}
Ok(Schema::new(tables))
}
fn sqlite_table(
name: &str,
columns: &[Row],
foreign_keys: &[Row],
unique: &[String],
indexed: &[String],
) -> TableDef {
let mut fields = Vec::with_capacity(columns.len());
let mut key: Vec<(String, i64)> = Vec::new();
for column in columns {
let column_name: String = column.get("column_name").unwrap_or_default();
let declared: String = column.get("declared").unwrap_or_default();
let not_null = column.get::<i64>("not_null").unwrap_or(0) == 1;
let pk_pos = column.get::<i64>("pk_pos").unwrap_or(0);
if pk_pos > 0 {
key.push((column_name.clone(), pk_pos));
}
fields.push(FieldDef {
kind: field_kind(&declared),
required: not_null || pk_pos > 0,
unique: unique.contains(&column_name),
indexed: indexed.contains(&column_name),
..FieldDef::new(column_name, FieldKind::text())
});
}
key.sort_by_key(|(_, position)| *position);
TableDef {
name: name.to_owned(),
primary_key: key.into_iter().map(|(column, _)| column).collect(),
foreign_keys: foreign_keys
.iter()
.filter_map(|row| {
Some(ForeignKey::new(
row.get::<String>("child_column")?,
row.get::<String>("parent")?,
))
})
.collect(),
fields,
}
}
async fn postgres_schema(db: &dyn Database, whole_catalog: bool) -> Result<Schema, DbError> {
let names = db
.query(&Statement::new(format!(
"SELECT table_name AS name FROM information_schema.tables \
WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' \
{harness} \
ORDER BY table_name ASC",
harness = harness_exclusion(whole_catalog, "table_name")
)))
.await?
.rows
.iter()
.filter_map(|row| row.get::<String>("name"))
.collect::<Vec<_>>();
let mut tables = Vec::with_capacity(names.len());
for name in names {
let columns = db
.query(&Statement::with_values(
"SELECT column_name AS column_name, udt_name AS declared, \
is_nullable AS is_nullable FROM information_schema.columns \
WHERE table_schema = current_schema() AND table_name = ? \
ORDER BY ordinal_position",
vec![text(&name)],
))
.await?;
let keys = db
.query(&Statement::with_values(
"SELECT tc.constraint_type AS constraint_type, kcu.column_name AS column_name, \
kcu.ordinal_position AS pos \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage kcu \
ON kcu.constraint_name = tc.constraint_name \
AND kcu.constraint_schema = tc.constraint_schema \
WHERE tc.table_schema = current_schema() AND tc.table_name = ? \
AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE') \
ORDER BY tc.constraint_name, kcu.ordinal_position",
vec![text(&name)],
))
.await?;
let foreign_keys = db
.query(&Statement::with_values(
"SELECT kcu.column_name AS child_column, ccu.table_name AS parent \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage kcu \
ON kcu.constraint_name = tc.constraint_name \
AND kcu.constraint_schema = tc.constraint_schema \
JOIN information_schema.constraint_column_usage ccu \
ON ccu.constraint_name = tc.constraint_name \
AND ccu.constraint_schema = tc.constraint_schema \
WHERE tc.table_schema = current_schema() AND tc.table_name = ? \
AND tc.constraint_type = 'FOREIGN KEY' \
ORDER BY kcu.column_name, ccu.table_name, ccu.column_name",
vec![text(&name)],
))
.await?;
let indexes = db
.query(&Statement::with_values(
"SELECT indexdef AS indexdef FROM pg_indexes \
WHERE schemaname = current_schema() AND tablename = ? \
ORDER BY indexname",
vec![text(&name)],
))
.await?;
tables.push(postgres_table(
&name,
&columns.rows,
&keys.rows,
&foreign_keys.rows,
&indexes.rows,
));
}
Ok(Schema::new(tables))
}
fn postgres_table(
name: &str,
columns: &[Row],
keys: &[Row],
foreign_keys: &[Row],
indexes: &[Row],
) -> TableDef {
let mut key: Vec<(String, i64)> = Vec::new();
let mut unique_constraints: Vec<String> = Vec::new();
for row in keys {
let column: String = row.get::<String>("column_name").unwrap_or_default();
if row.get::<String>("constraint_type").as_deref() == Some("PRIMARY KEY") {
key.push((column, row.get::<i64>("pos").unwrap_or(0)));
} else {
unique_constraints.push(column);
}
}
key.sort_by_key(|(_, position)| *position);
let primary_key: Vec<String> = key.into_iter().map(|(column, _)| column).collect();
let mut unique: Vec<String> = Vec::new();
let mut indexed_columns: Vec<String> = Vec::new();
let column_names: Vec<String> = columns
.iter()
.filter_map(|row| row.get::<String>("column_name"))
.collect();
for index in indexes {
let Some(def) = index.get::<String>("indexdef") else {
continue;
};
let Some(members) = pg_index_columns(&def) else {
continue;
};
if members == primary_key {
continue;
}
let is_unique = def.starts_with("CREATE UNIQUE INDEX");
if members.len() != 1 {
continue;
}
if !column_names.contains(&members[0]) {
continue;
}
if is_unique {
unique.push(members[0].clone());
} else {
indexed_columns.push(members[0].clone());
}
}
for column in unique_constraints {
if !unique.contains(&column) {
unique.push(column);
}
}
TableDef {
name: name.to_owned(),
primary_key,
foreign_keys: foreign_keys
.iter()
.filter_map(|row| {
Some(ForeignKey::new(
row.get::<String>("child_column")?,
row.get::<String>("parent")?,
))
})
.collect(),
fields: columns
.iter()
.filter_map(|row| {
let column_name: String = row.get::<String>("column_name")?;
Some(FieldDef {
kind: field_kind(&row.get::<String>("declared").unwrap_or_default()),
required: row.get::<String>("is_nullable").as_deref() == Some("NO"),
unique: unique.contains(&column_name),
indexed: indexed_columns.contains(&column_name),
..FieldDef::new(column_name, FieldKind::text())
})
})
.collect(),
}
}
fn pg_index_columns(def: &str) -> Option<Vec<String>> {
let body = def.split(" WHERE ").next()?;
let open = body.rfind('(')?;
let close = body.rfind(')')?;
if close < open {
return None;
}
Some(
body[open + 1..close]
.split(',')
.map(|entry| {
entry
.split_whitespace()
.next()
.unwrap_or_default()
.to_owned()
})
.collect(),
)
}
fn field_kind(declared: &str) -> FieldKind {
let lowered = declared.trim().to_ascii_lowercase();
let base = lowered
.split('(')
.next()
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("");
match base {
"uuid" => FieldKind::Uuid,
"json" | "jsonb" => FieldKind::Json,
"bool" | "boolean" => FieldKind::Boolean,
"date" | "datetime" | "timestamp" | "timestamptz" | "time" => FieldKind::Timestamp,
"int" | "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint" | "serial"
| "bigserial" => FieldKind::integer(),
"real" | "float" | "float4" | "float8" | "double" | "numeric" | "decimal" | "money" => {
FieldKind::real()
}
_ => FieldKind::text(),
}
}
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn text(value: &str) -> sea_query::Value {
sea_query::Value::String(Some(Box::new(value.to_owned())))
}
#[cfg(test)]
mod tests {
use super::*;
const DDL: &[&str] = &[
"CREATE TABLE parent (\
id TEXT PRIMARY KEY, \
email TEXT NOT NULL UNIQUE, \
note TEXT)",
"CREATE TABLE child (\
id TEXT PRIMARY KEY, \
parent_id TEXT NOT NULL REFERENCES parent (id), \
qty INTEGER, \
created_at TEXT)",
"CREATE INDEX child_by_created ON child (created_at)",
"CREATE UNIQUE INDEX child_qty_unique ON child (qty)",
];
async fn seed(db: &dyn Database) {
for statement in DDL {
db.execute(&Statement::new(*statement)).await.expect("ddl");
}
db.execute(&Statement::new(
"CREATE TABLE harness_secrets (\
name TEXT PRIMARY KEY, \
key_id TEXT NOT NULL)",
))
.await
.expect("a harness-named table");
for id in ["p1", "p2"] {
db.execute(&Statement::with_values(
"INSERT INTO parent (id, email, note) VALUES (?, ?, ?)",
vec![text(id), text(&format!("{id}@example.com")), text("")],
))
.await
.expect("parent row");
}
for (id, parent, qty) in [("c1", "p1", 1), ("c2", "p2", 2), ("c3", "p1", 3)] {
db.execute(&Statement::with_values(
"INSERT INTO child (id, parent_id, qty, created_at) VALUES (?, ?, ?, ?)",
vec![
text(id),
text(parent),
sea_query::Value::Int(Some(qty)),
text("2026-01-01T00:00:00Z"),
],
))
.await
.expect("child row");
}
}
async fn assert_catalog(db: &dyn Database) {
let schema = schema(db).await.expect("read the catalog");
let names: Vec<&str> = schema.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["child", "parent"],
"sorted, the harness prefix excluded"
);
assert!(
schema.table("harness_secrets").is_none(),
"no harness_ table is listable"
);
let whole = full_schema(db).await.expect("read the whole catalog");
let whole_names: Vec<&str> = whole.tables.iter().map(|t| t.name.as_str()).collect();
assert!(
whole_names.contains(&"harness_secrets"),
"the whole-catalog read still sees the harness-named table: {whole_names:?}"
);
assert!(
whole_names.len() > names.len(),
"the browsable catalog hid something the database holds"
);
let parent = schema.table("parent").expect("parent");
assert_eq!(parent.primary_key, ["id"]);
let id = parent.field("id").expect("id column");
assert!(id.required, "a primary key column is NOT NULL");
assert!(!id.unique, "the pk marker is its own fact, not also unique");
assert_eq!(id.kind.as_str(), "text");
let email = parent.field("email").expect("email column");
assert!(email.required);
assert!(email.unique, "a UNIQUE column reads as unique");
assert!(!email.indexed, "unique adds no second index");
let note = parent.field("note").expect("note column");
assert!(!note.required, "note is the nullable column");
assert!(!note.unique);
let child = schema.table("child").expect("child");
assert_eq!(child.primary_key, ["id"]);
assert_eq!(
child
.foreign_keys
.iter()
.map(|key| (key.field.as_str(), key.references.as_str()))
.collect::<Vec<_>>(),
[("parent_id", "parent")],
"the foreign key comes back"
);
let parent_id = child.field("parent_id").expect("parent_id column");
assert!(parent_id.required);
assert_eq!(
child.field("qty").expect("qty column").kind.as_str(),
"integer",
"an INTEGER column maps onto the vocabulary"
);
assert!(
child.field("qty").expect("qty column").unique,
"the unique INDEX reads as unique"
);
assert!(
child
.field("created_at")
.expect("created_at column")
.indexed,
"the plain index reads as indexed"
);
assert!(!parent_id.indexed && !child.field("id").expect("id").indexed);
assert_eq!(row_count(db, "parent").await.expect("count"), 2);
assert_eq!(row_count(db, "child").await.expect("count"), 3);
let page = rows(db, "child", &["id"], 2, 1).await.expect("page");
let ids: Vec<String> = page
.rows
.iter()
.map(|row| row.get::<String>("id").unwrap_or_default())
.collect();
assert_eq!(ids, ["c2", "c3"], "ordered by primary key, offset applies");
}
#[pollster::test]
async fn the_sqlite_catalog_reads_back_every_declared_fact() {
let db = cratefield_adapter_sqlite::SqliteDatabase::in_memory().expect("sqlite");
db.apply_migrations("probe", &[]).expect("ledger");
seed(&db).await;
assert_catalog(&db).await;
}
#[pollster::test]
async fn no_harness_table_is_listable_and_the_whole_catalog_names_them() {
let db = cratefield_adapter_sqlite::SqliteDatabase::in_memory().expect("sqlite");
db.apply_migrations("probe", &[]).expect("ledger");
db.execute(&Statement::new(
"CREATE TABLE harness_secret_keys (key_id TEXT PRIMARY KEY, wrapped TEXT)".to_owned(),
))
.await
.expect("harness-named table");
db.execute(&Statement::new(
"CREATE TABLE ordinary (id TEXT PRIMARY KEY)".to_owned(),
))
.await
.expect("ordinary table");
let browsable = schema(&db).await.expect("catalog");
let names: Vec<&str> = browsable.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["ordinary"],
"no harness_ table is listable, the ledger included: {names:?}"
);
let whole = full_schema(&db).await.expect("whole catalog");
let mut whole_names: Vec<&str> = whole.tables.iter().map(|t| t.name.as_str()).collect();
whole_names.sort_unstable();
assert_eq!(
whole_names,
["harness_migrations", "harness_secret_keys", "ordinary"],
"the whole-catalog read is the whole catalog: {whole_names:?}"
);
}
#[tokio::test]
async fn the_postgres_catalog_reads_back_the_same_facts() {
let Some(base) = cratefield_adapter_postgres::testing::base_url() else {
eprintln!(
"SKIP: {}",
cratefield_adapter_postgres::testing::skip_reason()
);
return;
};
let temp = cratefield_adapter_postgres::testing::TempDb::create(&base, "introspect")
.await
.expect("throwaway database");
let db = cratefield_adapter_postgres::Postgres::connect(&temp.url)
.await
.expect("connect");
seed(&db).await;
assert_catalog(&db).await;
db.close().await.expect("close");
temp.finish().await;
}
#[pollster::test]
async fn an_unreadable_catalog_is_an_error_not_an_empty_schema() {
let err = schema(&EmptyCatalog).await.expect_err("no catalog answers");
match err {
DbError::Query(message) => {
assert!(message.contains("either catalog"), "{message}");
}
other => panic!("expected a query error, got {other:?}"),
}
}
struct EmptyCatalog;
#[async_trait::async_trait]
impl Database for EmptyCatalog {
async fn execute(&self, _stmt: &Statement) -> Result<u64, DbError> {
Err(DbError::Execute("not a database".to_owned()))
}
async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
if stmt.sql.trim().starts_with("SELECT 1") {
Ok(Rows::new(Vec::new()))
} else {
Err(DbError::Query("no catalog here".to_owned()))
}
}
async fn batch_atomic(&self, _stmts: &[Statement]) -> Result<(), DbError> {
Err(DbError::Batch("no".to_owned()))
}
}
#[test]
fn identifiers_are_quoted_so_a_catalog_name_cannot_become_a_statement() {
assert_eq!(quote_ident("venture"), "\"venture\"");
assert_eq!(quote_ident("od\"d"), "\"od\"\"d\"");
}
#[test]
fn types_map_onto_the_vocabulary_with_an_explicit_text_fallback() {
assert_eq!(field_kind("TEXT").as_str(), "text");
assert_eq!(field_kind("varchar(254)").as_str(), "text");
assert_eq!(field_kind("INTEGER").as_str(), "integer");
assert_eq!(field_kind("bigint").as_str(), "integer");
assert_eq!(field_kind("int4").as_str(), "integer");
assert_eq!(field_kind("float8").as_str(), "real");
assert_eq!(field_kind("DOUBLE PRECISION").as_str(), "real");
assert_eq!(field_kind("BOOLEAN").as_str(), "boolean");
assert_eq!(field_kind("timestamptz").as_str(), "timestamp");
assert_eq!(field_kind("uuid").as_str(), "uuid");
assert_eq!(field_kind("jsonb").as_str(), "json");
assert_eq!(field_kind("BLOB").as_str(), "text");
}
}