use diesel::OptionalExtension as _;
use diesel::result::{DatabaseErrorKind, Error as DieselError};
use diesel_async::pooled_connection::deadpool::Pool;
use diesel_async::{AsyncConnection as _, AsyncPgConnection, RunQueryDsl as _};
use super::model::{CapsuleModel, DataCapsuleError, FieldSpec, Record, check_ident};
use super::store::{CapsuleFuture, CapsuleStore, ImportBatch, ModelData};
#[derive(Clone)]
pub struct PgCapsuleStore {
pool: Pool<AsyncPgConnection>,
}
impl std::fmt::Debug for PgCapsuleStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PgCapsuleStore").finish_non_exhaustive()
}
}
impl PgCapsuleStore {
#[must_use]
pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
Self { pool }
}
async fn conn(
&self,
) -> Result<
diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>,
DataCapsuleError,
> {
self.pool
.get()
.await
.map_err(|e| DataCapsuleError::Store(format!("no database connection: {e}")))
}
}
fn quote(name: &str) -> Result<String, DataCapsuleError> {
check_ident(name)?;
Ok(format!("\"{name}\""))
}
fn store_error(context: &str, error: &DieselError) -> DataCapsuleError {
match error {
DieselError::DatabaseError(
DatabaseErrorKind::UniqueViolation | DatabaseErrorKind::ForeignKeyViolation,
info,
) => DataCapsuleError::Conflict(format!("{context}: {}", info.message())),
other => DataCapsuleError::Store(format!("{context}: {other}")),
}
}
fn travels_as_text(data_type: &str) -> bool {
let base = data_type.trim_end_matches("[]");
base.starts_with("numeric") || base == "real" || base == "double precision" || base == "money"
}
struct Column {
field: FieldSpec,
base_type: String,
}
fn select_expr(column: &Column) -> Result<String, DataCapsuleError> {
let col = quote(&column.field.name)?;
let base = column.base_type.as_str();
if base == "money" {
return Ok(format!("{col}::numeric::text AS {col}"));
}
if base.ends_with("[]") {
let (json, literal) = if base == "money[]" {
(
format!("{col}::numeric[]::text[]"),
format!("{col}::numeric[]::text"),
)
} else if travels_as_text(base) {
(format!("{col}::text[]"), format!("{col}::text"))
} else {
(col.clone(), format!("{col}::text"))
};
return Ok(format!(
"CASE WHEN array_dims({col}) IS NULL OR array_dims({col}) ~ '^(\\[1:[0-9]+\\])+$' \
THEN to_jsonb({json}) ELSE to_jsonb({literal}) END AS {col}"
));
}
Ok(if travels_as_text(base) {
let cast = if column.base_type.ends_with("[]") {
"text[]"
} else {
"text"
};
format!("{col}::{cast} AS {col}")
} else {
col
})
}
#[derive(diesel::QueryableByName)]
struct ColumnRow {
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
data_type: String,
#[diesel(sql_type = diesel::sql_types::Text)]
base_type: String,
#[diesel(sql_type = diesel::sql_types::Text)]
full_base_type: String,
#[diesel(sql_type = diesel::sql_types::Text)]
plain_type: String,
#[diesel(sql_type = diesel::sql_types::Bool)]
nullable: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
generated: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
has_default: bool,
}
#[derive(diesel::QueryableByName)]
struct RowsJson {
#[diesel(sql_type = diesel::sql_types::Text)]
rows: String,
}
#[derive(diesel::QueryableByName)]
struct SequenceRow {
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
seq: Option<String>,
}
async fn describe_table(
conn: &mut AsyncPgConnection,
table: &str,
) -> Result<Vec<Column>, DataCapsuleError> {
let rows: Vec<ColumnRow> = diesel::sql_query(
"WITH cols AS ( \
SELECT a.attnum, a.attname, a.atttypid, a.atttypmod, a.attnotnull, a.atthasdef, \
a.attidentity, r.relname, n.nspname, \
(WITH RECURSIVE outer_chain(oid, base, kind, category, elem, own, tmod) AS ( \
SELECT t.oid, t.typbasetype, t.typtype, t.typcategory, t.typelem, \
t.typtypmod, -1 \
UNION ALL \
SELECT b.oid, b.typbasetype, b.typtype, b.typcategory, b.typelem, \
b.typtypmod, c.own \
FROM outer_chain c JOIN pg_type b ON b.oid = c.base \
WHERE c.kind = 'd'), \
outer_base AS (SELECT * FROM outer_chain WHERE kind <> 'd' LIMIT 1), \
elem_chain(oid, base, kind, own, tmod) AS ( \
SELECT e.oid, e.typbasetype, e.typtype, e.typtypmod, -1 \
FROM outer_base o JOIN pg_type e ON e.oid = o.elem \
WHERE o.category = 'A' \
UNION ALL \
SELECT b.oid, b.typbasetype, b.typtype, b.typtypmod, c.own \
FROM elem_chain c JOIN pg_type b ON b.oid = c.base WHERE c.kind = 'd') \
SELECT ARRAY[ \
COALESCE( \
(SELECT format_type(oid, NULL) || '[]' FROM elem_chain \
WHERE kind <> 'd' LIMIT 1), \
(SELECT format_type(oid, NULL) FROM outer_base)), \
COALESCE( \
(SELECT format_type(oid, tmod) || '[]' FROM elem_chain \
WHERE kind <> 'd' LIMIT 1), \
(SELECT format_type(oid, tmod) FROM outer_base))]) AS bases \
FROM pg_attribute a \
JOIN pg_class r ON r.oid = a.attrelid \
JOIN pg_type t ON t.oid = a.atttypid \
JOIN pg_namespace n ON n.oid = r.relnamespace \
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped) \
SELECT k.attname::text AS name, \
format_type(k.atttypid, k.atttypmod) AS data_type, \
k.bases[1] AS base_type, \
k.bases[2] AS full_base_type, \
format_type(k.atttypid, NULL) AS plain_type, \
NOT k.attnotnull AS nullable, \
COALESCE(c.is_generated = 'ALWAYS', false) AS generated, \
k.atthasdef OR k.attidentity <> '' AS has_default \
FROM cols k \
LEFT JOIN information_schema.columns c \
ON c.table_schema = k.nspname AND c.table_name = k.relname \
AND c.column_name = k.attname \
ORDER BY k.attnum",
)
.bind::<diesel::sql_types::Text, _>(quote(table)?)
.load(conn)
.await
.map_err(|e| store_error(&format!("describe {table}"), &e))?;
if rows.is_empty() {
return Err(DataCapsuleError::Store(format!(
"table {table:?} does not exist"
)));
}
Ok(rows
.into_iter()
.map(|r| {
let mut field = FieldSpec::new(r.name, r.data_type);
field.nullable = r.nullable;
field.generated = r.generated;
field.has_default = r.has_default;
if r.base_type != r.plain_type {
field.base_type = Some(r.full_base_type);
}
Column {
field,
base_type: r.base_type,
}
})
.collect())
}
fn fields(columns: Vec<Column>) -> Vec<FieldSpec> {
columns.into_iter().map(|c| c.field).collect()
}
#[derive(diesel::QueryableByName)]
struct CastRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
ok: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
exact: bool,
}
fn subject_column<'c>(
model: &CapsuleModel,
columns: &'c [Column],
) -> Result<&'c Column, DataCapsuleError> {
columns
.iter()
.find(|c| c.field.name == model.subject_column)
.ok_or_else(|| {
DataCapsuleError::Store(format!(
"{} has no column {:?}",
model.table, model.subject_column
))
})
}
async fn check_subject(
conn: &mut AsyncPgConnection,
model: &CapsuleModel,
columns: &[Column],
subject: &str,
) -> Result<(), DataCapsuleError> {
let column = subject_column(model, columns)?;
let subject_type = column.field.data_type.as_str();
let bare = match column.base_type.as_str() {
"character" => "bpchar",
"bit" => "bit varying",
other => other,
};
let invalid = |reason: String| {
DataCapsuleError::InvalidInput(format!(
"subject {subject:?} is not a valid {subject_type} for {}: {reason}",
model.table
))
};
let read = format!("CAST($1 AS {subject_type})");
let exact = match bare {
"real" | "double precision" => {
format!(
"CAST(CAST({read} AS text) AS numeric) IS NOT DISTINCT FROM CAST($1 AS numeric)"
)
}
"money" => format!("CAST({read} AS numeric) IS NOT DISTINCT FROM CAST($1 AS numeric)"),
"date" => {
format!("CAST({read} AS timestamp) IS NOT DISTINCT FROM CAST($1 AS timestamp)")
}
_ => format!("CAST({read} AS {bare}) IS NOT DISTINCT FROM CAST($1 AS {bare})"),
};
let row = diesel::sql_query(format!("SELECT {read} IS NOT NULL AS ok, {exact} AS exact"))
.bind::<diesel::sql_types::Text, _>(subject)
.get_result::<CastRow>(conn)
.await
.map_err(|e| invalid(e.to_string()))?;
if !row.ok {
return Err(invalid("the cast gives NULL".to_owned()));
}
if !row.exact {
return Err(invalid("the column type changes the value".to_owned()));
}
Ok(())
}
async fn fetch_rows(
conn: &mut AsyncPgConnection,
model: &CapsuleModel,
columns: &[Column],
subject: &str,
) -> Result<Vec<Record>, DataCapsuleError> {
let subject_type = subject_column(model, columns)?.field.data_type.as_str();
let exprs = columns
.iter()
.map(select_expr)
.collect::<Result<Vec<_>, _>>()?
.join(", ");
let pk = quote(&model.primary_key)?;
let sql = format!(
"SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.{pk}), '[]'::jsonb)::text AS rows \
FROM (SELECT {exprs} FROM {table} WHERE {subject_col} = CAST($1 AS {subject_type})) t",
table = quote(&model.table)?,
subject_col = quote(&model.subject_column)?,
);
let row: RowsJson = diesel::sql_query(sql)
.bind::<diesel::sql_types::Text, _>(subject)
.get_result(conn)
.await
.map_err(|e| store_error(&format!("fetch {}", model.table), &e))?;
serde_json::from_str(&row.rows).map_err(|e| DataCapsuleError::Json {
file: model.table.clone(),
message: e.to_string(),
})
}
impl CapsuleStore for PgCapsuleStore {
fn describe<'a>(&'a self, model: &'a CapsuleModel) -> CapsuleFuture<'a, Vec<FieldSpec>> {
Box::pin(async move {
let mut conn = self.conn().await?;
describe_table(&mut conn, &model.table).await.map(fields)
})
}
fn fetch<'a>(
&'a self,
model: &'a CapsuleModel,
subject: &'a str,
) -> CapsuleFuture<'a, Vec<Record>> {
Box::pin(async move {
let mut conn = self.conn().await?;
let columns = describe_table(&mut conn, &model.table).await?;
check_subject(&mut conn, model, &columns, subject).await?;
fetch_rows(&mut conn, model, &columns, subject).await
})
}
fn fetch_subject<'a>(
&'a self,
models: &'a [CapsuleModel],
subject: &'a str,
) -> CapsuleFuture<'a, Vec<ModelData>> {
Box::pin(async move {
let mut conn = self.conn().await?;
conn.transaction::<Vec<ModelData>, DataCapsuleError, _>(async move |conn| {
diesel::sql_query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
.execute(conn)
.await
.map_err(|e| store_error("start snapshot", &e))?;
for model in models {
let table = quote(&model.table)?;
diesel::sql_query(format!("LOCK TABLE {table} IN ACCESS SHARE MODE"))
.execute(conn)
.await
.map_err(|e| store_error(&format!("lock {}", model.table), &e))?;
}
let mut data = Vec::with_capacity(models.len());
for model in models {
let columns = describe_table(conn, &model.table).await?;
check_subject(conn, model, &columns, subject).await?;
let rows = fetch_rows(conn, model, &columns, subject).await?;
data.push((fields(columns), rows));
}
Ok(data)
})
.await
})
}
fn insert_all<'a>(&'a self, batches: &'a [ImportBatch<'a>]) -> CapsuleFuture<'a, ()> {
Box::pin(async move {
refuse_ledgered(batches)?;
let mut statements = Vec::new();
for batch in batches.iter().filter(|b| !b.records.is_empty()) {
let rows =
serde_json::to_string(batch.records).map_err(|e| DataCapsuleError::Json {
file: batch.model.file.clone(),
message: e.to_string(),
})?;
statements.push((insert_sql(batch)?, rows, batch));
}
let mut conn = self.conn().await?;
let result: Result<(), DataCapsuleError> = conn
.transaction::<(), DataCapsuleError, _>(async move |conn| {
for (sql, rows, batch) in &statements {
diesel::sql_query(sql.as_str())
.bind::<diesel::sql_types::Text, _>(rows)
.execute(conn)
.await
.map_err(|e| {
store_error(&format!("import {}", batch.model.table), &e)
})?;
}
diesel::sql_query("SET CONSTRAINTS ALL IMMEDIATE")
.execute(conn)
.await
.map_err(|e| store_error("check deferred constraints", &e))?;
let mut columns = Vec::new();
for (_, _, batch) in &statements {
for field in batch.model.fields.iter().filter(|f| !f.generated) {
columns.push((batch, field));
}
}
let pairs: Vec<(&str, &str)> = columns
.iter()
.map(|(batch, field)| (batch.model.table.as_str(), field.name.as_str()))
.collect();
let sequences = serial_sequences(conn, &pairs).await?;
let mut owned = Vec::new();
for ((batch, field), seq) in columns.into_iter().zip(sequences) {
if let Some(seq) = seq {
let keys = imported_keys(batch.records, &field.name);
owned.push((
seq,
batch.model.table.as_str(),
field.name.as_str(),
keys,
));
}
}
owned.sort_unstable();
owned.dedup_by(|a, b| a.0 == b.0);
for (seq, _, _, _) in &owned {
diesel::sql_query(
"SELECT pg_advisory_xact_lock(hashtextextended(\
'autumn.capsule.sequence:' || $1::regclass::oid::text, 0))",
)
.bind::<diesel::sql_types::Text, _>(seq)
.execute(conn)
.await
.map_err(|e| store_error(&format!("lock sequence {seq}"), &e))?;
}
let mut moves = Vec::new();
for (seq, table, column, keys) in &owned {
if let Some(next) = plan_sequence(conn, table, column, seq, keys).await? {
moves.push(next);
}
}
for (seq, value, table) in moves {
diesel::sql_query("SELECT setval($1::regclass, $2)")
.bind::<diesel::sql_types::Text, _>(seq)
.bind::<diesel::sql_types::BigInt, _>(value)
.execute(conn)
.await
.map_err(|e| {
store_error(&format!("advance sequence of {table}"), &e)
})?;
}
Ok(())
})
.await;
result
})
}
}
impl From<DieselError> for DataCapsuleError {
fn from(error: DieselError) -> Self {
store_error("transaction", &error)
}
}
fn is_money(field: &FieldSpec) -> bool {
matches!(
field.base_type.as_deref().unwrap_or(&field.data_type),
"money" | "money[]"
)
}
fn money_expr(field: &FieldSpec) -> String {
let name = &field.name;
if field.base_type.as_deref().unwrap_or(&field.data_type) == "money" {
return format!("(e.j ->> '{name}')::numeric::money");
}
format!(
"CASE jsonb_typeof(e.j -> '{name}') \
WHEN 'array' THEN translate((e.j -> '{name}')::text, '[]', '{{}}')::numeric[]::money[] \
WHEN 'string' THEN (e.j ->> '{name}')::numeric[]::money[] END"
)
}
fn refuse_ledgered(batches: &[ImportBatch<'_>]) -> Result<(), DataCapsuleError> {
batches
.iter()
.find(|b| !b.records.is_empty() && crate::ledger::is_ledgered_table(&b.model.table))
.map_or(Ok(()), |batch| {
Err(DataCapsuleError::InvalidInput(format!(
"table {} is ledgered: a capsule import records no ledger revision, so it \
cannot write to that table",
batch.model.table
)))
})
}
fn insert_sql(batch: &ImportBatch<'_>) -> Result<String, DataCapsuleError> {
let columns = batch
.model
.fields
.iter()
.filter(|f| !f.generated)
.map(|f| quote(&f.name))
.collect::<Result<Vec<_>, _>>()?;
if columns.is_empty() {
return Err(DataCapsuleError::Store(format!(
"manifest of {} has no columns",
batch.model.table
)));
}
let table = quote(&batch.model.table)?;
let money: Vec<&str> = batch
.model
.fields
.iter()
.filter(|f| !f.generated && is_money(f))
.map(|f| f.name.as_str())
.collect();
if money.is_empty() {
let columns = columns.join(", ");
return Ok(format!(
"INSERT INTO {table} ({columns}) OVERRIDING SYSTEM VALUE \
SELECT {columns} FROM jsonb_populate_recordset(NULL::{table}, $1::jsonb)"
));
}
let mut exprs = Vec::with_capacity(columns.len());
for field in batch.model.fields.iter().filter(|f| !f.generated) {
let col = quote(&field.name)?;
exprs.push(if is_money(field) {
money_expr(field)
} else {
format!("r.{col}")
});
}
let skip = money
.iter()
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(", ");
Ok(format!(
"INSERT INTO {table} ({columns}) OVERRIDING SYSTEM VALUE \
SELECT {exprs} FROM jsonb_array_elements($1::jsonb) AS e(j) \
CROSS JOIN LATERAL jsonb_populate_record(NULL::{table}, e.j - ARRAY[{skip}]::text[]) AS r",
columns = columns.join(", "),
exprs = exprs.join(", "),
))
}
#[allow(clippy::struct_excessive_bools)] #[derive(diesel::QueryableByName)]
struct SequencePlan {
#[diesel(sql_type = diesel::sql_types::BigInt)]
target: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
cache: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
min: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
max: i64,
#[diesel(sql_type = diesel::sql_types::Bool)]
needed: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
can_update: bool,
#[diesel(sql_type = diesel::sql_types::Bool)]
cycle: bool,
}
#[derive(diesel::QueryableByName)]
struct SelectGrant {
#[diesel(sql_type = diesel::sql_types::Bool)]
can_select: bool,
}
#[derive(diesel::QueryableByName)]
struct SequencePosition {
#[diesel(sql_type = diesel::sql_types::BigInt)]
last_value: i64,
#[diesel(sql_type = diesel::sql_types::Bool)]
is_called: bool,
}
fn imported_keys(records: &[Record], column: &str) -> Vec<String> {
records
.iter()
.filter_map(|row| match row.get(column)? {
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::String(s) => Some(s.clone()),
_ => None,
})
.filter(|key| is_decimal(key))
.collect()
}
fn is_decimal(s: &str) -> bool {
let (mantissa, exponent) = s.split_once(['e', 'E']).unwrap_or((s, "0"));
let mantissa = mantissa.strip_prefix(['-', '+']).unwrap_or(mantissa);
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let digits = |part: &str| part.len() <= 1000 && part.bytes().all(|b| b.is_ascii_digit());
!(whole.is_empty() && fraction.is_empty())
&& digits(whole)
&& digits(fraction)
&& exponent
.parse::<i16>()
.is_ok_and(|e| (-1000..=1000).contains(&e))
}
async fn serial_sequences(
conn: &mut AsyncPgConnection,
pairs: &[(&str, &str)],
) -> Result<Vec<Option<String>>, DataCapsuleError> {
if pairs.is_empty() {
return Ok(Vec::new());
}
let tables = pairs
.iter()
.map(|(table, _)| quote(table))
.collect::<Result<Vec<_>, _>>()?;
let columns: Vec<&str> = pairs.iter().map(|(_, column)| *column).collect();
let rows: Vec<SequenceRow> = diesel::sql_query(
"SELECT pg_get_serial_sequence(t.tbl, t.col) AS seq \
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY AS t(tbl, col, ord) \
ORDER BY t.ord",
)
.bind::<diesel::sql_types::Array<diesel::sql_types::Text>, _>(tables)
.bind::<diesel::sql_types::Array<diesel::sql_types::Text>, _>(columns)
.get_results(conn)
.await
.map_err(|e| store_error("sequences of the imported columns", &e))?;
Ok(rows.into_iter().map(|row| row.seq).collect())
}
async fn plan_sequence(
conn: &mut AsyncPgConnection,
table: &str,
column: &str,
seq: &str,
keys: &[String],
) -> Result<Option<(String, i64, String)>, DataCapsuleError> {
let seq = seq.to_owned();
let target = format!("{table}.{column}");
if keys.is_empty() {
return Ok(None);
}
let state: SelectGrant =
diesel::sql_query("SELECT has_sequence_privilege($1::regclass, 'SELECT') AS can_select")
.bind::<diesel::sql_types::Text, _>(&seq)
.get_result(conn)
.await
.map_err(|e| store_error(&format!("sequence of {target}"), &e))?;
if !state.can_select {
return Err(DataCapsuleError::Store(format!(
"the import role has no SELECT privilege on the sequence of {target}: \
import cannot read where it is"
)));
}
let position: SequencePosition =
diesel::sql_query(format!("SELECT last_value, is_called FROM {seq}"))
.get_result(conn)
.await
.map_err(|e| store_error(&format!("sequence of {target}"), &e))?;
let plan: Option<SequencePlan> = diesel::sql_query(
"SELECT s.m AS target, s.cache, s.min, s.max, s.cycle, CASE WHEN s.inc > 0 \
THEN s.m >= s.next ELSE s.m <= s.next END AS needed, \
has_sequence_privilege($1::regclass, 'UPDATE') AS can_update \
FROM (SELECT CASE WHEN q.seqincrement > 0 \
THEN MAX(k.v) FILTER (WHERE k.on_path) \
ELSE MIN(k.v) FILTER (WHERE k.on_path) \
END::bigint AS m, \
CASE WHEN $4 THEN $3::numeric + q.seqincrement ELSE $3::numeric END \
AS next, \
q.seqincrement AS inc, q.seqcache AS cache, \
q.seqmin AS min, q.seqmax AS max, q.seqcycle AS cycle \
FROM pg_sequence q CROSS JOIN LATERAL ( \
SELECT r.v, r.v = trunc(r.v) \
AND r.v BETWEEN -9223372036854775808 AND 9223372036854775807 \
AND mod(r.v - q.seqstart, q.seqincrement) = 0 AS on_path \
FROM (SELECT CAST(t AS numeric) AS v FROM unnest($2::text[]) AS u(t)) r) k \
WHERE q.seqrelid = $1::regclass \
GROUP BY q.seqincrement, q.seqstart, q.seqcache, q.seqmin, q.seqmax, \
q.seqcycle) s \
WHERE s.m IS NOT NULL",
)
.bind::<diesel::sql_types::Text, _>(&seq)
.bind::<diesel::sql_types::Array<diesel::sql_types::Text>, _>(keys)
.bind::<diesel::sql_types::BigInt, _>(position.last_value)
.bind::<diesel::sql_types::Bool, _>(position.is_called)
.get_result(conn)
.await
.optional()
.map_err(|e| store_error(&format!("sequence of {target}"), &e))?;
let Some(plan) = plan else {
return Ok(None);
};
if plan.cache > 1 {
return Err(DataCapsuleError::NotConfigured(format!(
"the sequence of {target} caches {} values; import needs CACHE 1",
plan.cache
)));
}
if plan.cycle {
return Err(DataCapsuleError::NotConfigured(format!(
"the sequence of {target} cycles; import needs NO CYCLE"
)));
}
if !plan.needed {
return Ok(None);
}
if !plan.can_update {
return Err(DataCapsuleError::Store(format!(
"the import role has no UPDATE privilege on the sequence of {target}"
)));
}
if plan.target < plan.min || plan.target > plan.max {
return Err(DataCapsuleError::Conflict(format!(
"key {} of {target} is outside its sequence range ({}..{})",
plan.target, plan.min, plan.max
)));
}
Ok(Some((seq, plan.target, target)))
}
#[cfg(test)]
mod tests {
use super::*;
inventory::submit! {
crate::ledger::LedgeredTableDescriptor { table: "capsule_ledgered_notes" }
}
fn manifest(table: &str) -> super::super::ModelManifest {
super::super::ModelManifest {
table: table.to_owned(),
primary_key: "id".to_owned(),
subject_column: "id".to_owned(),
fields: vec![FieldSpec::new("id", "bigint")],
relationships: Vec::new(),
blob_columns: Vec::new(),
record_count: 1,
file: format!("records/{table}.json"),
}
}
#[test]
fn an_import_into_a_ledgered_table_is_refused() {
let ledgered = manifest("capsule_ledgered_notes");
let plain = manifest("capsule_plain_notes");
let mut row = Record::new();
row.insert("id".to_owned(), serde_json::json!(1));
let rows = vec![row];
let none: Vec<Record> = Vec::new();
assert!(refuse_ledgered(&[ImportBatch::new(&plain, &rows)]).is_ok());
assert!(refuse_ledgered(&[ImportBatch::new(&ledgered, &none)]).is_ok());
let err = refuse_ledgered(&[
ImportBatch::new(&plain, &rows),
ImportBatch::new(&ledgered, &rows),
])
.expect_err("refused");
assert!(err.to_string().contains("capsule_ledgered_notes"), "{err}");
}
#[tokio::test]
async fn insert_all_refuses_a_ledgered_table_before_it_connects() {
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
let manager =
AsyncDieselConnectionManager::<AsyncPgConnection>::new("postgres://127.0.0.1:1/none");
let store = PgCapsuleStore::new(Pool::builder(manager).build().expect("pool"));
let ledgered = manifest("capsule_ledgered_notes");
let mut row = Record::new();
row.insert("id".to_owned(), serde_json::json!(1));
let rows = vec![row];
let err = store
.insert_all(&[ImportBatch::new(&ledgered, &rows)])
.await
.expect_err("refused");
assert!(matches!(err, DataCapsuleError::InvalidInput(_)), "{err:?}");
}
#[test]
fn keys_are_the_decimals_that_numeric_reads() {
for s in [
"1",
"-1",
"+1",
"1.00",
"1.",
".5",
"1e+06",
"1E-3",
"9007199254740993.00",
] {
assert!(is_decimal(s), "{s}");
}
for s in [
"", ".", "-", "1.2.3", "1e", "1e5000", "abc", "0x10", " 1", "NaN", "inf",
] {
assert!(!is_decimal(s), "{s}");
}
let rows: Vec<Record> = serde_json::from_str(
r#"[{"id": 1}, {"id": "2.00"}, {"id": 3.0}, {"id": "x"}, {"id": null}, {}]"#,
)
.unwrap();
assert_eq!(imported_keys(&rows, "id"), ["1", "2.00", "3.0"]);
}
#[test]
fn lossy_number_types_travel_as_text() {
for t in [
"numeric",
"numeric(10,2)",
"real",
"double precision",
"money",
"numeric[]",
] {
assert!(travels_as_text(t), "{t}");
}
for t in [
"bigint",
"integer",
"text",
"jsonb",
"timestamp with time zone",
] {
assert!(!travels_as_text(t), "{t}");
}
}
#[test]
fn select_expr_casts_only_lossy_types() {
let col = |name: &str, base: &str| Column {
field: FieldSpec::new(name, "domain_name"),
base_type: base.to_owned(),
};
assert_eq!(
select_expr(&col("n", "numeric")).unwrap(),
"\"n\"::text AS \"n\""
);
assert_eq!(
select_expr(&col("n", "real[]")).unwrap(),
"CASE WHEN array_dims(\"n\") IS NULL OR array_dims(\"n\") ~ '^(\\[1:[0-9]+\\])+$' \
THEN to_jsonb(\"n\"::text[]) ELSE to_jsonb(\"n\"::text) END AS \"n\""
);
assert_eq!(
select_expr(&col("t", "text[]")).unwrap(),
"CASE WHEN array_dims(\"t\") IS NULL OR array_dims(\"t\") ~ '^(\\[1:[0-9]+\\])+$' \
THEN to_jsonb(\"t\") ELSE to_jsonb(\"t\"::text) END AS \"t\""
);
assert_eq!(select_expr(&col("id", "bigint")).unwrap(), "\"id\"");
assert_eq!(
select_expr(&col("m", "money")).unwrap(),
"\"m\"::numeric::text AS \"m\""
);
assert_eq!(
select_expr(&col("m", "money[]")).unwrap(),
"CASE WHEN array_dims(\"m\") IS NULL OR array_dims(\"m\") ~ '^(\\[1:[0-9]+\\])+$' \
THEN to_jsonb(\"m\"::numeric[]::text[]) ELSE to_jsonb(\"m\"::numeric[]::text) END AS \"m\""
);
assert!(select_expr(&col("a\"b", "text")).is_err());
}
#[test]
fn insert_sql_skips_generated_columns() {
let mut model = super::super::ModelManifest {
table: "t".to_owned(),
primary_key: "id".to_owned(),
subject_column: "id".to_owned(),
fields: vec![
FieldSpec::new("id", "bigint"),
FieldSpec::new("total", "numeric").generated(),
],
relationships: Vec::new(),
blob_columns: Vec::new(),
record_count: 0,
file: "records/t.json".to_owned(),
};
let records = Vec::new();
let sql = insert_sql(&ImportBatch {
model: &model,
records: &records,
})
.unwrap();
assert_eq!(
sql,
"INSERT INTO \"t\" (\"id\") OVERRIDING SYSTEM VALUE SELECT \"id\" FROM \
jsonb_populate_recordset(NULL::\"t\", $1::jsonb)"
);
model.fields.push(FieldSpec::new("fee", "money").nullable());
let sql = insert_sql(&ImportBatch {
model: &model,
records: &records,
})
.unwrap();
assert_eq!(
sql,
"INSERT INTO \"t\" (\"id\", \"fee\") OVERRIDING SYSTEM VALUE SELECT r.\"id\", \
(e.j ->> 'fee')::numeric::money FROM jsonb_array_elements($1::jsonb) AS e(j) \
CROSS JOIN LATERAL jsonb_populate_record(NULL::\"t\", e.j - ARRAY['fee']::text[]) AS r"
);
let mut cash = FieldSpec::new("fee", "cash").nullable();
cash.base_type = Some("money".to_owned());
model.fields[2] = cash;
let sql = insert_sql(&ImportBatch {
model: &model,
records: &records,
})
.unwrap();
assert!(sql.contains("(e.j ->> 'fee')::numeric::money"), "{sql}");
model.fields[2] = FieldSpec::new("fee", "money[]").nullable();
let sql = insert_sql(&ImportBatch {
model: &model,
records: &records,
})
.unwrap();
assert!(
sql.contains(
"CASE jsonb_typeof(e.j -> 'fee') \
WHEN 'array' THEN translate((e.j -> 'fee')::text, '[]', '{}')::numeric[]::money[] \
WHEN 'string' THEN (e.j ->> 'fee')::numeric[]::money[] END"
),
"{sql}"
);
model.fields.clear();
assert!(
insert_sql(&ImportBatch {
model: &model,
records: &records,
})
.is_err()
);
}
}