Skip to main content

umbral_core/
backup.rs

1//! Backup and recovery: dump every registered model's rows to JSON,
2//! load them back.
3//!
4//! The two halves are symmetric. [`dump`] walks
5//! `migrate::registered_models()`, runs `SELECT * FROM <table>` for
6//! each, and dispatches per column's [`SqlType`] to read every value
7//! out as a `serde_json::Value`. [`load`] reads the JSON back and
8//! inserts each row through `sqlx::query` with the same per-column
9//! dispatch on the binding side.
10//!
11//! The on-disk format is one JSON document with a small envelope:
12//!
13//! ```json
14//! {
15//!   "umbral_dump_version": "1",
16//!   "exported_at": "2026-05-30T17:00:00Z",
17//!   "models": [
18//!     { "table": "post", "rows": [{"id": 1, "title": "..."}] },
19//!     { "table": "tag",  "rows": [{"id": 1, "name": "..."}] }
20//!   ]
21//! }
22//! ```
23//!
24//! ## v1 scope
25//!
26//! - Every `SqlType` variant in the M3 catalogue: integer widths,
27//!   floats, bool, text, date/time/timestamptz, uuid, plus their
28//!   nullable forms.
29//! - One-shot dump + load. No partial dumps, no streaming.
30//! - Order-independent: `load` doesn't assume a particular model
31//!   sequence; rows insert into existing tables (the schema must be
32//!   present, which is what `umbral-cli migrate` is for).
33//!
34//! ## Deferred
35//!
36//! - Schema-snapshot embedding for forward-compat (the dump captures
37//!   data only; the receiver needs a compatible schema).
38//! - Streaming for very large databases.
39//! - Selective dump / load with model filters.
40
41use std::path::Path;
42use std::str::FromStr;
43
44use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
45use ipnetwork::IpNetwork;
46use mac_address::MacAddress;
47use rust_decimal::Decimal;
48use serde::{Deserialize, Serialize};
49use serde_json::{Map, Value};
50use sqlx::Row;
51use uuid::Uuid;
52
53use crate::db::DbPool;
54use crate::migrate::{Column, ModelMeta};
55use crate::orm::{ArrayElement, SqlType, TsVector};
56
57const DUMP_VERSION: &str = "1";
58
59/// One table resolved against the live schema, paired with the dump
60/// rows to load into it. Borrows the rows out of the [`Dump`] so the
61/// restore never copies row data.
62type ResolvedTable<'a> = (ModelMeta, &'a [Map<String, Value>]);
63
64/// The on-disk envelope. `models` order is the order [`dump`] wrote
65/// them in (sorted by table name for determinism). `exported_at` is
66/// captured at dump time for traceability; [`load`] doesn't read it.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Dump {
69    pub umbral_dump_version: String,
70    pub exported_at: String,
71    pub models: Vec<ModelDump>,
72}
73
74/// One table's worth of rows. The `table` field carries the SQL
75/// table name (`Model::TABLE`), not the Rust struct name, so a load
76/// against a schema that ran `#[umbral(table = "...")]` overrides
77/// still finds the right destination.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ModelDump {
80    pub table: String,
81    pub rows: Vec<Map<String, Value>>,
82}
83
84/// Errors the dump / load pipeline can produce.
85#[derive(Debug)]
86pub enum BackupError {
87    Io(std::io::Error),
88    Json(serde_json::Error),
89    Sqlx(sqlx::Error),
90    /// Dump version doesn't match what this build knows how to load.
91    /// The version string in the file is included for the diagnostic.
92    UnsupportedVersion(String),
93    /// A column in the loaded JSON doesn't exist on the model's
94    /// schema. Surfaced so a forward-incompatible dump fails loudly
95    /// instead of silently skipping data.
96    UnknownColumn {
97        table: String,
98        column: String,
99    },
100    /// A value in the loaded JSON doesn't match the expected
101    /// `SqlType` shape (e.g. a string where the schema wants an
102    /// integer). Carries the table / column / observed value type
103    /// for the diagnostic.
104    TypeMismatch {
105        table: String,
106        column: String,
107        expected: SqlType,
108        got: String,
109    },
110    /// The dump carries two entries for the same table. A merged or
111    /// hand-edited dump would otherwise load the first entry and route
112    /// the rest to `skipped_tables` (the "unknown schema" bucket),
113    /// masking the duplicate. Fail loudly instead.
114    DuplicateTable {
115        table: String,
116    },
117}
118
119impl std::fmt::Display for BackupError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            BackupError::Io(e) => write!(f, "umbral backup: io: {e}"),
123            BackupError::Json(e) => write!(f, "umbral backup: json: {e}"),
124            BackupError::Sqlx(e) => write!(f, "umbral backup: sqlx: {e}"),
125            BackupError::UnsupportedVersion(v) => write!(
126                f,
127                "umbral backup: dump version `{v}` is not supported by this build \
128                 (this build knows version `{DUMP_VERSION}`)"
129            ),
130            BackupError::UnknownColumn { table, column } => write!(
131                f,
132                "umbral backup: column `{table}.{column}` in the dump isn't in the \
133                 current schema; run `umbral-cli migrate` first or update the dump"
134            ),
135            BackupError::TypeMismatch {
136                table,
137                column,
138                expected,
139                got,
140            } => write!(
141                f,
142                "umbral backup: column `{table}.{column}` expects {expected:?} but the \
143                 dump has {got}"
144            ),
145            BackupError::DuplicateTable { table } => write!(
146                f,
147                "umbral backup: dump contains two entries for table `{table}`; a dump must \
148                 carry one entry per table (was it merged or hand-edited?)"
149            ),
150        }
151    }
152}
153
154impl std::error::Error for BackupError {}
155
156impl From<std::io::Error> for BackupError {
157    fn from(e: std::io::Error) -> Self {
158        Self::Io(e)
159    }
160}
161
162impl From<serde_json::Error> for BackupError {
163    fn from(e: serde_json::Error) -> Self {
164        Self::Json(e)
165    }
166}
167
168impl From<sqlx::Error> for BackupError {
169    fn from(e: sqlx::Error) -> Self {
170        Self::Sqlx(e)
171    }
172}
173
174/// Dump every registered model's rows to a [`Dump`] value. The
175/// ambient pool (published by `App::build`) is the source.
176pub async fn dump() -> Result<Dump, BackupError> {
177    let pool = crate::db::pool_dispatched();
178    let mut models = crate::migrate::registered_models();
179    models.sort_by(|a, b| a.table.cmp(&b.table));
180
181    let mut out: Vec<ModelDump> = Vec::with_capacity(models.len());
182    for model in models {
183        out.push(dump_one(pool, &model).await?);
184    }
185    Ok(Dump {
186        umbral_dump_version: DUMP_VERSION.to_string(),
187        exported_at: Utc::now().to_rfc3339(),
188        models: out,
189    })
190}
191
192/// Convenience: dump and write the JSON to `path`.
193pub async fn dump_to_path(path: &Path) -> Result<(), BackupError> {
194    let dump = dump().await?;
195    let json = serde_json::to_string_pretty(&dump)?;
196    std::fs::write(path, json)?;
197    Ok(())
198}
199
200/// Load a [`Dump`] back into the database. Schema must already exist
201/// (run `umbral-cli migrate` first). Rows insert via `sqlx::query` with
202/// per-column type dispatch; the ambient pool is the target.
203pub async fn load(dump: &Dump) -> Result<LoadReport, BackupError> {
204    if dump.umbral_dump_version != DUMP_VERSION {
205        return Err(BackupError::UnsupportedVersion(
206            dump.umbral_dump_version.clone(),
207        ));
208    }
209    let pool = crate::db::pool_dispatched();
210    let registered = crate::migrate::registered_models();
211    let by_table: std::collections::HashMap<String, ModelMeta> = registered
212        .into_iter()
213        .map(|m| (m.table.clone(), m))
214        .collect();
215
216    let mut report = LoadReport::default();
217
218    // Resolve every dump entry against the live schema. Duplicate table
219    // entries are a hard error (was the dump merged / hand-edited?); an
220    // entry whose table isn't in the schema is skipped with a warning
221    // (a dump from a richer schema still restores the tables this build
222    // knows). We look up WITHOUT removing so a legitimate duplicate is
223    // caught here rather than silently routed to `skipped_tables`.
224    let mut resolved: Vec<ResolvedTable<'_>> = Vec::new();
225    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
226    for model in &dump.models {
227        let Some(meta) = by_table.get(&model.table) else {
228            report.skipped_tables.push(model.table.clone());
229            continue;
230        };
231        if !seen.insert(model.table.clone()) {
232            return Err(BackupError::DuplicateTable {
233                table: model.table.clone(),
234            });
235        }
236        resolved.push((meta.clone(), model.rows.as_slice()));
237    }
238
239    // Topologically order by FK dependency so a child table never loads
240    // before its parent — the dump is written in alphabetical order,
241    // which puts `comment` before `post` and fails FK checks on
242    // restore. Cycles / self-references degrade to the dump's original
243    // order for the affected nodes rather than erroring.
244    let ordered = topo_order_by_fk(resolved);
245
246    // One transaction for the whole restore so a mid-load failure rolls
247    // back cleanly instead of leaving a half-populated database.
248    match pool {
249        DbPool::Sqlite(p) => load_all_sqlite(p, &ordered, &mut report).await?,
250        DbPool::Postgres(p) => load_all_postgres(p, &ordered, &mut report).await?,
251    }
252    Ok(report)
253}
254
255/// Order the resolved `(meta, rows)` pairs so every table appears after
256/// the tables its foreign keys reference. A Kahn-style walk: repeatedly
257/// emit the nodes whose FK targets are all already emitted, preserving
258/// the input order among ready nodes for determinism. FK targets that
259/// aren't part of this load (e.g. a table not in the dump) impose no
260/// ordering constraint. A dependency cycle (or a self-referential FK)
261/// can't be fully ordered by table; the walk breaks it by emitting the
262/// lowest-input-index remaining node, which reproduces the old
263/// best-effort behaviour for those nodes.
264fn topo_order_by_fk(items: Vec<ResolvedTable<'_>>) -> Vec<ResolvedTable<'_>> {
265    let present: std::collections::HashSet<String> =
266        items.iter().map(|(m, _)| m.table.clone()).collect();
267
268    let mut deps: std::collections::HashMap<String, std::collections::HashSet<String>> =
269        std::collections::HashMap::new();
270    for (m, _) in &items {
271        let mut d = std::collections::HashSet::new();
272        for col in &m.fields {
273            if let Some(target) = &col.fk_target {
274                if target != &m.table && present.contains(target) {
275                    d.insert(target.clone());
276                }
277            }
278        }
279        deps.insert(m.table.clone(), d);
280    }
281
282    let order_index: std::collections::HashMap<String, usize> = items
283        .iter()
284        .enumerate()
285        .map(|(i, (m, _))| (m.table.clone(), i))
286        .collect();
287
288    let mut emitted_set: std::collections::HashSet<String> = std::collections::HashSet::new();
289    let mut emitted: Vec<String> = Vec::new();
290    let mut remaining: Vec<String> = items.iter().map(|(m, _)| m.table.clone()).collect();
291
292    while !remaining.is_empty() {
293        let mut ready: Vec<String> = remaining
294            .iter()
295            .filter(|t| deps[*t].iter().all(|d| emitted_set.contains(d)))
296            .cloned()
297            .collect();
298        if ready.is_empty() {
299            // Cycle: break it deterministically by the lowest input index.
300            let pick = remaining
301                .iter()
302                .min_by_key(|t| order_index[*t])
303                .cloned()
304                .expect("remaining is non-empty");
305            ready.push(pick);
306        }
307        ready.sort_by_key(|t| order_index[t]);
308        for t in ready {
309            emitted_set.insert(t.clone());
310            emitted.push(t.clone());
311            remaining.retain(|x| x != &t);
312        }
313    }
314
315    let mut by_table: std::collections::HashMap<String, ResolvedTable<'_>> = items
316        .into_iter()
317        .map(|(m, r)| (m.table.clone(), (m, r)))
318        .collect();
319    emitted
320        .into_iter()
321        .filter_map(|t| by_table.remove(&t))
322        .collect()
323}
324
325async fn load_all_sqlite(
326    pool: &sqlx::SqlitePool,
327    ordered: &[ResolvedTable<'_>],
328    report: &mut LoadReport,
329) -> Result<(), BackupError> {
330    let mut tx = pool.begin().await?;
331    for (meta, rows) in ordered {
332        let inserted = insert_rows_sqlite(&mut tx, meta, rows).await?;
333        report.rows_loaded += inserted;
334        report.tables_loaded.push(meta.table.clone());
335    }
336    tx.commit().await?;
337    Ok(())
338}
339
340async fn load_all_postgres(
341    pool: &sqlx::PgPool,
342    ordered: &[ResolvedTable<'_>],
343    report: &mut LoadReport,
344) -> Result<(), BackupError> {
345    let mut tx = pool.begin().await?;
346    for (meta, rows) in ordered {
347        let inserted = insert_rows_postgres(&mut tx, meta, rows).await?;
348        report.rows_loaded += inserted;
349        report.tables_loaded.push(meta.table.clone());
350    }
351    // A restore inserts explicit primary keys, but a BIGSERIAL sequence
352    // still starts at 1 — the first ORM insert after restore then
353    // collides on the PK. Advance each integer-PK table's sequence past
354    // its restored max so new inserts don't duplicate a restored id.
355    for (meta, _) in ordered {
356        reset_pg_sequence(&mut tx, meta).await?;
357    }
358    tx.commit().await?;
359    Ok(())
360}
361
362/// Advance a table's owning sequence past `MAX(pk)` on Postgres. Only
363/// integer PKs own a BIGSERIAL sequence; String / Uuid / composite /
364/// absent PKs are skipped. `pg_get_serial_sequence` returns NULL for an
365/// integer PK that doesn't own a sequence (an app-assigned id), so the
366/// NULL guard keeps `setval(NULL, ...)` from ever running.
367async fn reset_pg_sequence(
368    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
369    meta: &ModelMeta,
370) -> Result<(), BackupError> {
371    let Some(pk) = meta.fields.iter().find(|c| c.primary_key) else {
372        return Ok(());
373    };
374    if !matches!(
375        pk.ty,
376        SqlType::BigInt | SqlType::Integer | SqlType::SmallInt
377    ) {
378        return Ok(());
379    }
380    let seq: Option<String> = sqlx::query_scalar("SELECT pg_get_serial_sequence($1, $2)")
381        .bind(&meta.table)
382        .bind(&pk.name)
383        .fetch_one(&mut **tx)
384        .await?;
385    let Some(seq) = seq else {
386        return Ok(());
387    };
388    // `is_called = false` means the NEXT nextval() returns exactly this
389    // value, so `MAX(pk) + 1` is the next id handed out. On an empty
390    // table MAX is NULL → COALESCE → 0 → next id is 1.
391    let reset_sql = format!(
392        "SELECT setval($1, COALESCE((SELECT MAX({pk}) FROM {tbl}), 0) + 1, false)",
393        pk = quoted_ident(&pk.name),
394        tbl = quoted_ident(&meta.table),
395    );
396    sqlx::query(&reset_sql)
397        .bind(&seq)
398        .fetch_one(&mut **tx)
399        .await?;
400    Ok(())
401}
402
403/// Convenience: read the JSON from `path` and load it.
404pub async fn load_from_path(path: &Path) -> Result<LoadReport, BackupError> {
405    let text = std::fs::read_to_string(path)?;
406    let dump: Dump = serde_json::from_str(&text)?;
407    load(&dump).await
408}
409
410/// What [`load`] did. Tables present in the dump but not in the
411/// current schema land in `skipped_tables` (not an error; the dump
412/// might be from a richer schema).
413#[derive(Debug, Default, Clone)]
414pub struct LoadReport {
415    pub tables_loaded: Vec<String>,
416    pub skipped_tables: Vec<String>,
417    pub rows_loaded: u64,
418}
419
420// =========================================================================
421// Per-table dispatch.
422// =========================================================================
423
424async fn dump_one(pool: &DbPool, model: &ModelMeta) -> Result<ModelDump, BackupError> {
425    match pool {
426        DbPool::Sqlite(pool) => dump_one_sqlite(pool, model).await,
427        DbPool::Postgres(pool) => dump_one_postgres(pool, model).await,
428    }
429}
430
431async fn dump_one_sqlite(
432    pool: &sqlx::SqlitePool,
433    model: &ModelMeta,
434) -> Result<ModelDump, BackupError> {
435    let sql = format!(
436        "SELECT {} FROM {}",
437        column_list(model),
438        quoted_ident(&model.table)
439    );
440    let rows = sqlx::query(&sql).fetch_all(pool).await?;
441
442    let mut out: Vec<Map<String, Value>> = Vec::with_capacity(rows.len());
443    for row in rows {
444        let mut obj = Map::new();
445        for col in &model.fields {
446            obj.insert(col.name.clone(), column_to_json(&row, col)?);
447        }
448        out.push(obj);
449    }
450    Ok(ModelDump {
451        table: model.table.clone(),
452        rows: out,
453    })
454}
455
456async fn insert_rows_sqlite(
457    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
458    model: &ModelMeta,
459    rows: &[Map<String, Value>],
460) -> Result<u64, BackupError> {
461    if rows.is_empty() {
462        return Ok(0);
463    }
464    let sql = format!(
465        "INSERT INTO {} ({}) VALUES ({})",
466        quoted_ident(&model.table),
467        column_list(model),
468        sqlite_placeholders(model.fields.len())
469    );
470
471    let mut count: u64 = 0;
472    for row in rows {
473        // Surface unknown columns in the dump explicitly so a forward-
474        // incompatible dump fails loudly instead of silently dropping data.
475        for k in row.keys() {
476            if !model.fields.iter().any(|c| &c.name == k) {
477                return Err(BackupError::UnknownColumn {
478                    table: model.table.clone(),
479                    column: k.clone(),
480                });
481            }
482        }
483        let mut q = sqlx::query(&sql);
484        for col in &model.fields {
485            let val = row.get(&col.name).cloned().unwrap_or(Value::Null);
486            q = bind_value(q, &model.table, col, val)?;
487        }
488        q.execute(&mut **tx).await?;
489        count += 1;
490    }
491    Ok(count)
492}
493
494async fn dump_one_postgres(
495    pool: &sqlx::PgPool,
496    model: &ModelMeta,
497) -> Result<ModelDump, BackupError> {
498    let sql = format!(
499        "SELECT {} FROM {}",
500        column_list_pg_select(model),
501        quoted_ident(&model.table)
502    );
503    let rows = sqlx::query(&sql).fetch_all(pool).await?;
504
505    let mut out: Vec<Map<String, Value>> = Vec::with_capacity(rows.len());
506    for row in rows {
507        let mut obj = Map::new();
508        for col in &model.fields {
509            obj.insert(col.name.clone(), column_to_json_pg(&row, col)?);
510        }
511        out.push(obj);
512    }
513    Ok(ModelDump {
514        table: model.table.clone(),
515        rows: out,
516    })
517}
518
519async fn insert_rows_postgres(
520    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
521    model: &ModelMeta,
522    rows: &[Map<String, Value>],
523) -> Result<u64, BackupError> {
524    if rows.is_empty() {
525        return Ok(0);
526    }
527    let sql = format!(
528        "INSERT INTO {} ({}) VALUES ({})",
529        quoted_ident(&model.table),
530        column_list(model),
531        postgres_placeholders(model.fields.len())
532    );
533
534    let mut count: u64 = 0;
535    for row in rows {
536        for k in row.keys() {
537            if !model.fields.iter().any(|c| &c.name == k) {
538                return Err(BackupError::UnknownColumn {
539                    table: model.table.clone(),
540                    column: k.clone(),
541                });
542            }
543        }
544        let mut q = sqlx::query(&sql);
545        for col in &model.fields {
546            let val = row.get(&col.name).cloned().unwrap_or(Value::Null);
547            q = bind_value_pg(q, &model.table, col, val)?;
548        }
549        q.execute(&mut **tx).await?;
550        count += 1;
551    }
552    Ok(count)
553}
554
555fn quoted_ident(name: &str) -> String {
556    format!("\"{}\"", name.replace('"', "\"\""))
557}
558
559fn column_list(model: &ModelMeta) -> String {
560    model
561        .fields
562        .iter()
563        .map(|c| quoted_ident(&c.name))
564        .collect::<Vec<_>>()
565        .join(", ")
566}
567
568/// Like [`column_list`] but, for the Postgres dump SELECT, casts the
569/// text-backed Postgres-only types (`XML` / `LTREE` / `BIT VARYING`,
570/// gaps2 #70) to `text` and re-aliases them to their column name so the
571/// driver hands them back as a plain `String` (sqlx has no native
572/// `Decode` for those column types into `String`). The cast is harmless
573/// for every other column, so only the special types are wrapped.
574fn column_list_pg_select(model: &ModelMeta) -> String {
575    model
576        .fields
577        .iter()
578        .map(|c| {
579            if matches!(c.ty, SqlType::Xml | SqlType::Ltree | SqlType::Bit) {
580                let q = quoted_ident(&c.name);
581                format!("{q}::text AS {q}")
582            } else {
583                quoted_ident(&c.name)
584            }
585        })
586        .collect::<Vec<_>>()
587        .join(", ")
588}
589
590fn sqlite_placeholders(count: usize) -> String {
591    (0..count).map(|_| "?").collect::<Vec<_>>().join(", ")
592}
593
594fn postgres_placeholders(count: usize) -> String {
595    (1..=count)
596        .map(|idx| format!("${idx}"))
597        .collect::<Vec<_>>()
598        .join(", ")
599}
600
601// =========================================================================
602// Column-level dispatch on SqlType. The dump-side reader and the load-side
603// binder mirror each other variant-for-variant.
604// =========================================================================
605
606fn column_to_json(row: &sqlx::sqlite::SqliteRow, col: &Column) -> Result<Value, BackupError> {
607    let name = col.name.as_str();
608    // The nullable path always tries Option<T>. SQLite stores NULL
609    // explicitly so `try_get::<Option<T>>` is the safe read.
610    if col.nullable {
611        return Ok(match crate::migrate::fk_effective_type(col) {
612            SqlType::SmallInt | SqlType::Integer => row
613                .try_get::<Option<i32>, _>(name)?
614                .map_or(Value::Null, Value::from),
615            SqlType::BigInt => row
616                .try_get::<Option<i64>, _>(name)?
617                .map_or(Value::Null, Value::from),
618            SqlType::Real => row
619                .try_get::<Option<f32>, _>(name)?
620                .map_or(Value::Null, |v| Value::from(v as f64)),
621            SqlType::Double => row
622                .try_get::<Option<f64>, _>(name)?
623                .map_or(Value::Null, Value::from),
624            SqlType::Boolean => row
625                .try_get::<Option<bool>, _>(name)?
626                .map_or(Value::Null, Value::from),
627            SqlType::Text => row
628                .try_get::<Option<String>, _>(name)?
629                .map_or(Value::Null, Value::from),
630            SqlType::Date => row
631                .try_get::<Option<NaiveDate>, _>(name)?
632                .map_or(Value::Null, |v| Value::from(v.to_string())),
633            SqlType::Time => row
634                .try_get::<Option<NaiveTime>, _>(name)?
635                .map_or(Value::Null, |v| Value::from(v.to_string())),
636            SqlType::Timestamptz => row
637                .try_get::<Option<DateTime<Utc>>, _>(name)?
638                .map_or(Value::Null, |v| Value::from(v.to_rfc3339())),
639            SqlType::Uuid => row
640                .try_get::<Option<Uuid>, _>(name)?
641                .map_or(Value::Null, |v| Value::from(v.to_string())),
642            // The Json column already holds a serde_json::Value; the
643            // dump is the value itself (no string-wrapping). Reading via
644            // `try_get::<Option<Value>, _>` round-trips JSONB on Postgres
645            // and JSON-as-TEXT on SQLite via sqlx's `json` feature.
646            SqlType::Json => row
647                .try_get::<Option<Value>, _>(name)?
648                .unwrap_or(Value::Null),
649            // Array fields are Postgres-only and backup runs against
650            // the SQLite pool. The field.backend system check gates
651            // them at boot; reaching this means the boot path was
652            // bypassed.
653            SqlType::Array(_) => unreachable_array(&col.name),
654            SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => unreachable_network(&col.name),
655            SqlType::FullText => unreachable_pg_only(&col.name, "FullText (tsvector)"),
656            // gaps2 #70: text-backed Postgres types — backup's SQLite
657            // path is unreachable for them (field.backend gates at boot).
658            SqlType::Xml => unreachable_pg_only(&col.name, "Xml"),
659            SqlType::Ltree => unreachable_pg_only(&col.name, "Ltree"),
660            SqlType::Bit => unreachable_pg_only(&col.name, "Bit"),
661            // ForeignKey stores as i64 — same as BigInt.
662            SqlType::ForeignKey => row
663                .try_get::<Option<i64>, _>(name)?
664                .map_or(Value::Null, Value::from),
665            // BLOB / BYTEA. Backup format is a JSON array of u8
666            // numbers — exactly the same shape `json_to_sea_value`
667            // accepts on load.
668            SqlType::Bytes => row
669                .try_get::<Option<Vec<u8>>, _>(name)?
670                .map_or(Value::Null, |b| {
671                    Value::Array(b.into_iter().map(Value::from).collect())
672                }),
673            // BUG-10: Decimal is Postgres-only.
674            SqlType::Decimal => unreachable_pg_only(&col.name, "Decimal"),
675        });
676    }
677    // Non-nullable: same dispatch without the Option layer.
678    Ok(match crate::migrate::fk_effective_type(col) {
679        SqlType::SmallInt | SqlType::Integer => Value::from(row.try_get::<i32, _>(name)?),
680        SqlType::BigInt => Value::from(row.try_get::<i64, _>(name)?),
681        SqlType::Real => Value::from(row.try_get::<f32, _>(name)? as f64),
682        SqlType::Double => Value::from(row.try_get::<f64, _>(name)?),
683        SqlType::Boolean => Value::from(row.try_get::<bool, _>(name)?),
684        SqlType::Text => Value::from(row.try_get::<String, _>(name)?),
685        SqlType::Date => Value::from(row.try_get::<NaiveDate, _>(name)?.to_string()),
686        SqlType::Time => Value::from(row.try_get::<NaiveTime, _>(name)?.to_string()),
687        SqlType::Timestamptz => Value::from(row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339()),
688        SqlType::Uuid => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
689        SqlType::Json => row.try_get::<Value, _>(name)?,
690        SqlType::Array(_) => unreachable_array(&col.name),
691        SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => unreachable_network(&col.name),
692        SqlType::FullText => unreachable_pg_only(&col.name, "FullText (tsvector)"),
693        SqlType::Xml => unreachable_pg_only(&col.name, "Xml"),
694        SqlType::Ltree => unreachable_pg_only(&col.name, "Ltree"),
695        SqlType::Bit => unreachable_pg_only(&col.name, "Bit"),
696        // ForeignKey stores as i64 — same as BigInt.
697        SqlType::ForeignKey => Value::from(row.try_get::<i64, _>(name)?),
698        SqlType::Bytes => {
699            let bytes: Vec<u8> = row.try_get(name)?;
700            Value::Array(bytes.into_iter().map(Value::from).collect())
701        }
702        SqlType::Decimal => unreachable_pg_only(&col.name, "Decimal"),
703    })
704}
705
706fn column_to_json_pg(row: &sqlx::postgres::PgRow, col: &Column) -> Result<Value, BackupError> {
707    let name = col.name.as_str();
708    if col.nullable {
709        return Ok(match crate::migrate::fk_effective_type(col) {
710            SqlType::SmallInt => row
711                .try_get::<Option<i16>, _>(name)?
712                .map_or(Value::Null, Value::from),
713            SqlType::Integer => row
714                .try_get::<Option<i32>, _>(name)?
715                .map_or(Value::Null, Value::from),
716            SqlType::BigInt | SqlType::ForeignKey => row
717                .try_get::<Option<i64>, _>(name)?
718                .map_or(Value::Null, Value::from),
719            SqlType::Real => row
720                .try_get::<Option<f32>, _>(name)?
721                .map_or(Value::Null, |v| Value::from(v as f64)),
722            SqlType::Double => row
723                .try_get::<Option<f64>, _>(name)?
724                .map_or(Value::Null, Value::from),
725            SqlType::Boolean => row
726                .try_get::<Option<bool>, _>(name)?
727                .map_or(Value::Null, Value::from),
728            SqlType::Text => row
729                .try_get::<Option<String>, _>(name)?
730                .map_or(Value::Null, Value::from),
731            SqlType::Date => row
732                .try_get::<Option<NaiveDate>, _>(name)?
733                .map_or(Value::Null, |v| Value::from(v.to_string())),
734            SqlType::Time => row
735                .try_get::<Option<NaiveTime>, _>(name)?
736                .map_or(Value::Null, |v| Value::from(v.to_string())),
737            SqlType::Timestamptz => row
738                .try_get::<Option<DateTime<Utc>>, _>(name)?
739                .map_or(Value::Null, |v| Value::from(v.to_rfc3339())),
740            SqlType::Uuid => row
741                .try_get::<Option<Uuid>, _>(name)?
742                .map_or(Value::Null, |v| Value::from(v.to_string())),
743            SqlType::Json => row
744                .try_get::<Option<Value>, _>(name)?
745                .unwrap_or(Value::Null),
746            SqlType::Array(elem) => pg_array_column_to_json_nullable(row, name, elem)?,
747            SqlType::Inet | SqlType::Cidr => row
748                .try_get::<Option<IpNetwork>, _>(name)?
749                .map_or(Value::Null, |v| Value::from(v.to_string())),
750            SqlType::MacAddr => row
751                .try_get::<Option<MacAddress>, _>(name)?
752                .map_or(Value::Null, |v| Value::from(v.to_string())),
753            SqlType::FullText => row
754                .try_get::<Option<TsVector>, _>(name)?
755                .map_or(Value::Null, |v| Value::from(v.into_inner())),
756            // gaps2 #70: text-backed Postgres types dump via their text
757            // form. The dump query casts these columns to `text` (see
758            // `select_columns_sql`), so the driver hands back a `String`.
759            SqlType::Xml | SqlType::Ltree | SqlType::Bit => row
760                .try_get::<Option<String>, _>(name)?
761                .map_or(Value::Null, Value::from),
762            SqlType::Bytes => row
763                .try_get::<Option<Vec<u8>>, _>(name)?
764                .map_or(Value::Null, bytes_to_json),
765            SqlType::Decimal => row
766                .try_get::<Option<Decimal>, _>(name)?
767                .map_or(Value::Null, |v| Value::from(v.to_string())),
768        });
769    }
770    Ok(match crate::migrate::fk_effective_type(col) {
771        SqlType::SmallInt => Value::from(row.try_get::<i16, _>(name)?),
772        SqlType::Integer => Value::from(row.try_get::<i32, _>(name)?),
773        SqlType::BigInt | SqlType::ForeignKey => Value::from(row.try_get::<i64, _>(name)?),
774        SqlType::Real => Value::from(row.try_get::<f32, _>(name)? as f64),
775        SqlType::Double => Value::from(row.try_get::<f64, _>(name)?),
776        SqlType::Boolean => Value::from(row.try_get::<bool, _>(name)?),
777        SqlType::Text => Value::from(row.try_get::<String, _>(name)?),
778        SqlType::Date => Value::from(row.try_get::<NaiveDate, _>(name)?.to_string()),
779        SqlType::Time => Value::from(row.try_get::<NaiveTime, _>(name)?.to_string()),
780        SqlType::Timestamptz => Value::from(row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339()),
781        SqlType::Uuid => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
782        SqlType::Json => row.try_get::<Value, _>(name)?,
783        SqlType::Array(elem) => pg_array_column_to_json(row, name, elem)?,
784        SqlType::Inet | SqlType::Cidr => {
785            Value::from(row.try_get::<IpNetwork, _>(name)?.to_string())
786        }
787        SqlType::MacAddr => Value::from(row.try_get::<MacAddress, _>(name)?.to_string()),
788        SqlType::FullText => Value::from(row.try_get::<TsVector, _>(name)?.into_inner()),
789        // gaps2 #70: dump via the `::text` cast added in column_list_pg_select.
790        SqlType::Xml | SqlType::Ltree | SqlType::Bit => {
791            Value::from(row.try_get::<String, _>(name)?)
792        }
793        SqlType::Bytes => bytes_to_json(row.try_get::<Vec<u8>, _>(name)?),
794        SqlType::Decimal => Value::from(row.try_get::<Decimal, _>(name)?.to_string()),
795    })
796}
797
798fn pg_array_column_to_json_nullable(
799    row: &sqlx::postgres::PgRow,
800    name: &str,
801    elem: ArrayElement,
802) -> Result<Value, BackupError> {
803    Ok(match elem {
804        ArrayElement::SmallInt => row
805            .try_get::<Option<Vec<i16>>, _>(name)?
806            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
807        ArrayElement::Integer => row
808            .try_get::<Option<Vec<i32>>, _>(name)?
809            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
810        ArrayElement::BigInt => row
811            .try_get::<Option<Vec<i64>>, _>(name)?
812            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
813        ArrayElement::Real => row
814            .try_get::<Option<Vec<f32>>, _>(name)?
815            .map_or(Value::Null, |values| {
816                array_to_json(values, |v| Value::from(v as f64))
817            }),
818        ArrayElement::Double => row
819            .try_get::<Option<Vec<f64>>, _>(name)?
820            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
821        ArrayElement::Boolean => row
822            .try_get::<Option<Vec<bool>>, _>(name)?
823            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
824        ArrayElement::Text => row
825            .try_get::<Option<Vec<String>>, _>(name)?
826            .map_or(Value::Null, |values| array_to_json(values, Value::from)),
827        ArrayElement::Uuid => row
828            .try_get::<Option<Vec<Uuid>>, _>(name)?
829            .map_or(Value::Null, |values| {
830                array_to_json(values, |v| Value::from(v.to_string()))
831            }),
832    })
833}
834
835fn pg_array_column_to_json(
836    row: &sqlx::postgres::PgRow,
837    name: &str,
838    elem: ArrayElement,
839) -> Result<Value, BackupError> {
840    Ok(match elem {
841        ArrayElement::SmallInt => array_to_json(row.try_get::<Vec<i16>, _>(name)?, Value::from),
842        ArrayElement::Integer => array_to_json(row.try_get::<Vec<i32>, _>(name)?, Value::from),
843        ArrayElement::BigInt => array_to_json(row.try_get::<Vec<i64>, _>(name)?, Value::from),
844        ArrayElement::Real => {
845            array_to_json(row.try_get::<Vec<f32>, _>(name)?, |v| Value::from(v as f64))
846        }
847        ArrayElement::Double => array_to_json(row.try_get::<Vec<f64>, _>(name)?, Value::from),
848        ArrayElement::Boolean => array_to_json(row.try_get::<Vec<bool>, _>(name)?, Value::from),
849        ArrayElement::Text => array_to_json(row.try_get::<Vec<String>, _>(name)?, Value::from),
850        ArrayElement::Uuid => array_to_json(row.try_get::<Vec<Uuid>, _>(name)?, |v| {
851            Value::from(v.to_string())
852        }),
853    })
854}
855
856fn array_to_json<T>(values: Vec<T>, mut item: impl FnMut(T) -> Value) -> Value {
857    Value::Array(values.into_iter().map(&mut item).collect())
858}
859
860fn bytes_to_json(bytes: Vec<u8>) -> Value {
861    Value::Array(bytes.into_iter().map(Value::from).collect())
862}
863
864/// Boot-path-bypassed sentinel. Array fields are Postgres-only — the
865/// field.backend system check fires at App::build before any dump or
866/// load runs against the SQLite pool. If we reach here, the boot path
867/// was bypassed.
868fn unreachable_array(column: &str) -> ! {
869    panic!(
870        "umbral backup: column `{column}` is a Postgres-only Array; \
871         the field.backend system check should have failed boot. \
872         For portable list storage use SqlType::Json instead."
873    )
874}
875
876/// Phase 4.4 counterpart for Inet/Cidr/MacAddr — same gating story.
877fn unreachable_network(column: &str) -> ! {
878    panic!(
879        "umbral backup: column `{column}` is a Postgres-only network \
880         address type (Inet/Cidr/MacAddr); the field.backend system \
881         check should have failed boot."
882    )
883}
884
885/// Phase 4.3 generic sentinel for Postgres-only types (FullText today).
886fn unreachable_pg_only(column: &str, type_name: &str) -> ! {
887    panic!(
888        "umbral backup: column `{column}` is a Postgres-only {type_name} \
889         type; the field.backend system check should have failed boot."
890    )
891}
892
893fn bind_value<'q>(
894    q: SqliteQuery<'q>,
895    table: &str,
896    col: &Column,
897    val: Value,
898) -> Result<SqliteQuery<'q>, BackupError> {
899    // Null binding is the same shape regardless of SqlType — SQLite
900    // accepts a typed NULL on any column whose schema allows it.
901    if matches!(val, Value::Null) {
902        return Ok(match crate::migrate::fk_effective_type(col) {
903            SqlType::SmallInt | SqlType::Integer => q.bind(None::<i32>),
904            SqlType::BigInt => q.bind(None::<i64>),
905            SqlType::Real => q.bind(None::<f32>),
906            SqlType::Double => q.bind(None::<f64>),
907            SqlType::Boolean => q.bind(None::<bool>),
908            SqlType::Text => q.bind(None::<String>),
909            SqlType::Date => q.bind(None::<NaiveDate>),
910            SqlType::Time => q.bind(None::<NaiveTime>),
911            SqlType::Timestamptz => q.bind(None::<DateTime<Utc>>),
912            SqlType::Uuid => q.bind(None::<Uuid>),
913            SqlType::Json => q.bind(None::<Value>),
914            SqlType::Array(_) => unreachable_array(&col.name),
915            SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => unreachable_network(&col.name),
916            SqlType::FullText => unreachable_pg_only(&col.name, "FullText (tsvector)"),
917            // gaps2 #70: text-backed Postgres types — backup's SQLite
918            // path is unreachable for them (field.backend gates at boot).
919            SqlType::Xml => unreachable_pg_only(&col.name, "Xml"),
920            SqlType::Ltree => unreachable_pg_only(&col.name, "Ltree"),
921            SqlType::Bit => unreachable_pg_only(&col.name, "Bit"),
922            // ForeignKey stores as i64 — same as BigInt.
923            SqlType::ForeignKey => q.bind(None::<i64>),
924            SqlType::Bytes => q.bind(None::<Vec<u8>>),
925            SqlType::Decimal => unreachable_pg_only(&col.name, "Decimal"),
926        });
927    }
928    let mismatch = |got: &str| BackupError::TypeMismatch {
929        table: table.to_string(),
930        column: col.name.clone(),
931        expected: col.ty,
932        got: got.to_string(),
933    };
934    Ok(match crate::migrate::fk_effective_type(col) {
935        SqlType::SmallInt | SqlType::Integer => {
936            q.bind(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))? as i32)
937        }
938        SqlType::BigInt => q.bind(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))?),
939        SqlType::Real => q.bind(val.as_f64().ok_or_else(|| mismatch(json_type_name(&val)))? as f32),
940        SqlType::Double => q.bind(val.as_f64().ok_or_else(|| mismatch(json_type_name(&val)))?),
941        SqlType::Boolean => q.bind(
942            val.as_bool()
943                .ok_or_else(|| mismatch(json_type_name(&val)))?,
944        ),
945        SqlType::Text => q.bind(
946            val.as_str()
947                .ok_or_else(|| mismatch(json_type_name(&val)))?
948                .to_string(),
949        ),
950        SqlType::Date => {
951            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
952            q.bind(
953                s.parse::<NaiveDate>()
954                    .map_err(|_| mismatch("invalid date string"))?,
955            )
956        }
957        SqlType::Time => {
958            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
959            q.bind(
960                s.parse::<NaiveTime>()
961                    .map_err(|_| mismatch("invalid time string"))?,
962            )
963        }
964        SqlType::Timestamptz => {
965            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
966            q.bind(
967                DateTime::parse_from_rfc3339(s)
968                    .map_err(|_| mismatch("invalid rfc3339 timestamp"))?
969                    .with_timezone(&Utc),
970            )
971        }
972        SqlType::Uuid => {
973            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
974            q.bind(Uuid::parse_str(s).map_err(|_| mismatch("invalid uuid string"))?)
975        }
976        // Json columns hold a serde_json::Value verbatim — no string
977        // wrapping or parsing dance. sqlx's `json` feature handles the
978        // encode side: the Value serializes to JSON text (SQLite) or
979        // a JSONB byte stream (Postgres) before hitting the wire.
980        SqlType::Json => q.bind(val),
981        SqlType::Array(_) => unreachable_array(&col.name),
982        SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => unreachable_network(&col.name),
983        SqlType::FullText => unreachable_pg_only(&col.name, "FullText (tsvector)"),
984        SqlType::Xml => unreachable_pg_only(&col.name, "Xml"),
985        SqlType::Ltree => unreachable_pg_only(&col.name, "Ltree"),
986        SqlType::Bit => unreachable_pg_only(&col.name, "Bit"),
987        // ForeignKey stores as i64 — same as BigInt.
988        SqlType::ForeignKey => q.bind(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))?),
989        // BLOB: accept a JSON array of u8 numbers — the same shape the
990        // dump path emits.
991        SqlType::Bytes => q.bind(bytes_from_json(table, col, &val)?),
992        SqlType::Decimal => unreachable_pg_only(&col.name, "Decimal"),
993    })
994}
995
996type SqliteQuery<'q> = sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>;
997type PgQuery<'q> = sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>;
998
999fn bind_value_pg<'q>(
1000    q: PgQuery<'q>,
1001    table: &str,
1002    col: &Column,
1003    val: Value,
1004) -> Result<PgQuery<'q>, BackupError> {
1005    if matches!(val, Value::Null) {
1006        return Ok(match crate::migrate::fk_effective_type(col) {
1007            SqlType::SmallInt => q.bind(None::<i16>),
1008            SqlType::Integer => q.bind(None::<i32>),
1009            SqlType::BigInt | SqlType::ForeignKey => q.bind(None::<i64>),
1010            SqlType::Real => q.bind(None::<f32>),
1011            SqlType::Double => q.bind(None::<f64>),
1012            SqlType::Boolean => q.bind(None::<bool>),
1013            SqlType::Text => q.bind(None::<String>),
1014            SqlType::Date => q.bind(None::<NaiveDate>),
1015            SqlType::Time => q.bind(None::<NaiveTime>),
1016            SqlType::Timestamptz => q.bind(None::<DateTime<Utc>>),
1017            SqlType::Uuid => q.bind(None::<Uuid>),
1018            SqlType::Json => q.bind(None::<Value>),
1019            SqlType::Array(elem) => bind_null_array_pg(q, elem),
1020            SqlType::Inet | SqlType::Cidr => q.bind(None::<IpNetwork>),
1021            SqlType::MacAddr => q.bind(None::<MacAddress>),
1022            SqlType::FullText => q.bind(None::<TsVector>),
1023            // gaps2 #70: text-backed types bind their NULL as a text
1024            // parameter; Postgres applies the column's assignment cast.
1025            SqlType::Xml | SqlType::Ltree | SqlType::Bit => q.bind(None::<String>),
1026            SqlType::Bytes => q.bind(None::<Vec<u8>>),
1027            SqlType::Decimal => q.bind(None::<Decimal>),
1028        });
1029    }
1030    let mismatch = |got: &str| BackupError::TypeMismatch {
1031        table: table.to_string(),
1032        column: col.name.clone(),
1033        expected: col.ty,
1034        got: got.to_string(),
1035    };
1036    Ok(match crate::migrate::fk_effective_type(col) {
1037        SqlType::SmallInt => q.bind(
1038            i16::try_from(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))?)
1039                .map_err(|_| mismatch("number out of i16 range"))?,
1040        ),
1041        SqlType::Integer => q.bind(
1042            i32::try_from(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))?)
1043                .map_err(|_| mismatch("number out of i32 range"))?,
1044        ),
1045        SqlType::BigInt | SqlType::ForeignKey => {
1046            q.bind(val.as_i64().ok_or_else(|| mismatch(json_type_name(&val)))?)
1047        }
1048        SqlType::Real => q.bind(val.as_f64().ok_or_else(|| mismatch(json_type_name(&val)))? as f32),
1049        SqlType::Double => q.bind(val.as_f64().ok_or_else(|| mismatch(json_type_name(&val)))?),
1050        SqlType::Boolean => q.bind(
1051            val.as_bool()
1052                .ok_or_else(|| mismatch(json_type_name(&val)))?,
1053        ),
1054        SqlType::Text => q.bind(
1055            val.as_str()
1056                .ok_or_else(|| mismatch(json_type_name(&val)))?
1057                .to_string(),
1058        ),
1059        SqlType::Date => {
1060            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1061            q.bind(
1062                s.parse::<NaiveDate>()
1063                    .map_err(|_| mismatch("invalid date string"))?,
1064            )
1065        }
1066        SqlType::Time => {
1067            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1068            q.bind(
1069                s.parse::<NaiveTime>()
1070                    .map_err(|_| mismatch("invalid time string"))?,
1071            )
1072        }
1073        SqlType::Timestamptz => {
1074            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1075            q.bind(
1076                DateTime::parse_from_rfc3339(s)
1077                    .map_err(|_| mismatch("invalid rfc3339 timestamp"))?
1078                    .with_timezone(&Utc),
1079            )
1080        }
1081        SqlType::Uuid => {
1082            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1083            q.bind(Uuid::parse_str(s).map_err(|_| mismatch("invalid uuid string"))?)
1084        }
1085        SqlType::Json => q.bind(val),
1086        SqlType::Array(elem) => bind_array_pg(q, table, col, elem, &val)?,
1087        SqlType::Inet | SqlType::Cidr => {
1088            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1089            q.bind(IpNetwork::from_str(s).map_err(|_| mismatch("invalid network string"))?)
1090        }
1091        SqlType::MacAddr => {
1092            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1093            q.bind(MacAddress::from_str(s).map_err(|_| mismatch("invalid macaddr string"))?)
1094        }
1095        SqlType::FullText => {
1096            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1097            q.bind(TsVector::from(s))
1098        }
1099        // gaps2 #70: text-backed types bind their string form; Postgres
1100        // applies the column's assignment cast (text → xml / ltree /
1101        // bit) on insert.
1102        SqlType::Xml | SqlType::Ltree | SqlType::Bit => {
1103            let s = val.as_str().ok_or_else(|| mismatch(json_type_name(&val)))?;
1104            q.bind(s.to_string())
1105        }
1106        SqlType::Bytes => q.bind(bytes_from_json(table, col, &val)?),
1107        SqlType::Decimal => {
1108            let parsed = match &val {
1109                Value::String(s) => Decimal::from_str(s).ok(),
1110                Value::Number(n) => Decimal::from_str(&n.to_string()).ok(),
1111                _ => None,
1112            };
1113            q.bind(parsed.ok_or_else(|| mismatch(json_type_name(&val)))?)
1114        }
1115    })
1116}
1117
1118fn bind_null_array_pg<'q>(q: PgQuery<'q>, elem: ArrayElement) -> PgQuery<'q> {
1119    match elem {
1120        ArrayElement::SmallInt => q.bind(None::<Vec<i16>>),
1121        ArrayElement::Integer => q.bind(None::<Vec<i32>>),
1122        ArrayElement::BigInt => q.bind(None::<Vec<i64>>),
1123        ArrayElement::Real => q.bind(None::<Vec<f32>>),
1124        ArrayElement::Double => q.bind(None::<Vec<f64>>),
1125        ArrayElement::Boolean => q.bind(None::<Vec<bool>>),
1126        ArrayElement::Text => q.bind(None::<Vec<String>>),
1127        ArrayElement::Uuid => q.bind(None::<Vec<Uuid>>),
1128    }
1129}
1130
1131fn bind_array_pg<'q>(
1132    q: PgQuery<'q>,
1133    table: &str,
1134    col: &Column,
1135    elem: ArrayElement,
1136    val: &Value,
1137) -> Result<PgQuery<'q>, BackupError> {
1138    Ok(match elem {
1139        ArrayElement::SmallInt => q.bind(
1140            int_array_from_json(table, col, val)?
1141                .into_iter()
1142                .map(|n| {
1143                    i16::try_from(n)
1144                        .map_err(|_| type_mismatch(table, col, "element out of i16 range"))
1145                })
1146                .collect::<Result<Vec<_>, _>>()?,
1147        ),
1148        ArrayElement::Integer => q.bind(
1149            int_array_from_json(table, col, val)?
1150                .into_iter()
1151                .map(|n| {
1152                    i32::try_from(n)
1153                        .map_err(|_| type_mismatch(table, col, "element out of i32 range"))
1154                })
1155                .collect::<Result<Vec<_>, _>>()?,
1156        ),
1157        ArrayElement::BigInt => q.bind(int_array_from_json(table, col, val)?),
1158        ArrayElement::Real => q.bind(
1159            float_array_from_json(table, col, val)?
1160                .into_iter()
1161                .map(|n| n as f32)
1162                .collect::<Vec<_>>(),
1163        ),
1164        ArrayElement::Double => q.bind(float_array_from_json(table, col, val)?),
1165        ArrayElement::Boolean => q.bind(
1166            array_values(table, col, val)?
1167                .iter()
1168                .map(|v| {
1169                    v.as_bool()
1170                        .ok_or_else(|| type_mismatch(table, col, "non-boolean in array"))
1171                })
1172                .collect::<Result<Vec<_>, _>>()?,
1173        ),
1174        ArrayElement::Text => q.bind(
1175            array_values(table, col, val)?
1176                .iter()
1177                .map(|v| {
1178                    v.as_str()
1179                        .map(ToString::to_string)
1180                        .ok_or_else(|| type_mismatch(table, col, "non-string in array"))
1181                })
1182                .collect::<Result<Vec<_>, _>>()?,
1183        ),
1184        ArrayElement::Uuid => q.bind(
1185            array_values(table, col, val)?
1186                .iter()
1187                .map(|v| {
1188                    let s = v
1189                        .as_str()
1190                        .ok_or_else(|| type_mismatch(table, col, "non-string uuid in array"))?;
1191                    Uuid::parse_str(s)
1192                        .map_err(|_| type_mismatch(table, col, "invalid uuid string in array"))
1193                })
1194                .collect::<Result<Vec<_>, _>>()?,
1195        ),
1196    })
1197}
1198
1199fn array_values<'a>(
1200    table: &str,
1201    col: &Column,
1202    val: &'a Value,
1203) -> Result<&'a Vec<Value>, BackupError> {
1204    val.as_array()
1205        .ok_or_else(|| type_mismatch(table, col, json_type_name(val)))
1206}
1207
1208fn int_array_from_json(table: &str, col: &Column, val: &Value) -> Result<Vec<i64>, BackupError> {
1209    array_values(table, col, val)?
1210        .iter()
1211        .map(|v| {
1212            v.as_i64()
1213                .ok_or_else(|| type_mismatch(table, col, "non-integer in array"))
1214        })
1215        .collect()
1216}
1217
1218fn float_array_from_json(table: &str, col: &Column, val: &Value) -> Result<Vec<f64>, BackupError> {
1219    array_values(table, col, val)?
1220        .iter()
1221        .map(|v| {
1222            v.as_f64()
1223                .ok_or_else(|| type_mismatch(table, col, "non-number in array"))
1224        })
1225        .collect()
1226}
1227
1228fn bytes_from_json(table: &str, col: &Column, val: &Value) -> Result<Vec<u8>, BackupError> {
1229    let arr = val
1230        .as_array()
1231        .ok_or_else(|| type_mismatch(table, col, json_type_name(val)))?;
1232    let mut bytes: Vec<u8> = Vec::with_capacity(arr.len());
1233    for v in arr {
1234        let n = v
1235            .as_u64()
1236            .ok_or_else(|| type_mismatch(table, col, "non-number in bytes array"))?;
1237        if n > 255 {
1238            return Err(type_mismatch(table, col, "element out of u8 range"));
1239        }
1240        bytes.push(n as u8);
1241    }
1242    Ok(bytes)
1243}
1244
1245fn type_mismatch(table: &str, col: &Column, got: impl Into<String>) -> BackupError {
1246    BackupError::TypeMismatch {
1247        table: table.to_string(),
1248        column: col.name.clone(),
1249        expected: col.ty,
1250        got: got.into(),
1251    }
1252}
1253
1254fn json_type_name(v: &Value) -> &'static str {
1255    match v {
1256        Value::Null => "null",
1257        Value::Bool(_) => "boolean",
1258        Value::Number(_) => "number",
1259        Value::String(_) => "string",
1260        Value::Array(_) => "array",
1261        Value::Object(_) => "object",
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    #[test]
1270    fn placeholder_generation_matches_backend_syntax() {
1271        assert_eq!(sqlite_placeholders(3), "?, ?, ?");
1272        assert_eq!(postgres_placeholders(3), "$1, $2, $3");
1273    }
1274
1275    #[test]
1276    fn quoted_ident_escapes_double_quotes() {
1277        assert_eq!(quoted_ident("plain"), "\"plain\"");
1278        assert_eq!(quoted_ident("weird\"name"), "\"weird\"\"name\"");
1279    }
1280}