umbral-core 0.0.11

umbral internals: ORM, migrations, routing, DB backends, the Plugin trait. Do not depend on this directly; use the `umbral` facade.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! The database backend abstraction.
//!
//! `DatabaseBackend` is the seam where dialect differences live. The
//! trait sits on top of sea-query (which already abstracts dialect
//! rendering) and sqlx (which abstracts drivers); umbral adds the
//! umbral-specific reasoning layer on top so the system check (`check`)
//! and the migration engine (M5, `06-migration-engine.md`) can ask the
//! same questions of every backend.
//!
//! M4 ships two backends:
//!
//! - [`SqliteBackend`] — the runtime default. SQLite is what the M0–M3
//!   pool already opens; this just gives it a queryable identity in the
//!   check phase.
//! - [`PostgresBackend`] — declared and queryable for compatibility
//!   checks, but the umbral pool is still `sqlx::SqlitePool` at M4. The
//!   real `sqlx::PgPool` wiring lands when there's a real user need;
//!   the trait is in place so M5's migration engine can render Postgres
//!   DDL today and run it tomorrow.
//!
//! `MySqlBackend`, `OracleBackend`, and friends stay in the deferred
//! backlog per PRD §14.
//!
//! See `docs/specs/05-backends-and-system-check.md` for the target
//! design and the rationale for each `BackendFeature` variant.

use std::sync::OnceLock;

/// One umbral-supported relational backend.
///
/// Trait surface kept narrow at M4: identity (`name`), feature queries
/// (`supports`), and SQL-type mapping for the migration engine
/// (`map_type`). `quote_identifier`, `render_upsert`, and dialect-
/// specific rendering helpers get added when M5's migration engine and
/// bulk-insert paths need them; sea-query exposes those via per-backend
/// `QueryBuilder` types rather than a single dialect enum, so umbral
/// dispatches through `name()` for now and adds typed rendering helpers
/// when there's a real consumer.
pub trait DatabaseBackend: std::fmt::Debug + Send + Sync + 'static {
    /// Stable string identifier. `"postgres"`, `"sqlite"`, etc. Used as
    /// the matching key in `FieldSpec::supported_backends`, and shown
    /// in system-check error messages.
    fn name(&self) -> &'static str;

    /// Whether this backend supports the given feature. Used by the
    /// system check to gate Postgres-only field types (Array, HStore,
    /// jsonb) and by the migration engine to choose between
    /// `INSERT ... RETURNING` and `INSERT; last_insert_rowid()`.
    fn supports(&self, feature: BackendFeature) -> bool;

    /// Map an umbral `SqlType` to the sea-query `ColumnType` that
    /// renders the right native SQL column type on this backend. The
    /// migration engine (M5) reads this when generating `CREATE TABLE`.
    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType;

    /// Map a full column (type + per-column hints like `max_length`)
    /// to its sea-query `ColumnType`. Default impl delegates to
    /// `map_type` — backends that want to lift hints (Postgres
    /// rendering `Text + max_length=N` as `VARCHAR(N)`, for example)
    /// override this. The migration engine prefers this over
    /// `map_type` so the per-column attributes flow into DDL.
    fn map_column(&self, col: &crate::migrate::Column) -> sea_query::ColumnType {
        self.map_type(col.ty)
    }
}

/// Backend feature flags surfaced to umbral.
///
/// New variants land alongside new backend behaviour. Each variant
/// represents one capability that umbral reasons about explicitly; the
/// system check or the migration engine asks via `supports(feature)`
/// rather than hard-coding `if backend.name() == "postgres"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BackendFeature {
    /// `INSERT ... RETURNING column[, ...]` on inserts. Postgres + SQLite
    /// (3.35+); MySQL doesn't have it natively.
    InsertReturning,
    /// `INSERT ... ON CONFLICT (col) DO UPDATE` upserts. Postgres + SQLite.
    UpsertOnConflict,
    /// Array column types (`text[]`, `int[]`, etc.). Postgres only.
    ArrayColumns,
    /// `HStoreField` analogue: `key => value` text maps. Postgres only.
    HStoreColumns,
    /// Native `jsonb` column with index / operator support. Postgres only;
    /// SQLite supports JSON-as-TEXT but without the operator surface, so
    /// this flag is more honest as "real jsonb" than "any JSON."
    JsonbColumns,
    /// Native full-text search (`tsvector` + `to_tsquery`). Postgres only.
    FullTextSearch,
    /// CIDR / INET / MACADDR network address column types. Postgres only.
    CidrInet,
    /// Native `UUID` column type. Postgres only. SQLite has no uuid type: sqlx encodes a
    /// `Uuid` as its 16 raw bytes there, so umbral declares the column `BLOB` (gaps3 #80).
    UuidNative,
    /// Native `BOOLEAN` column type. Postgres + SQLite (since 3.23); MySQL
    /// historically encodes as TINYINT.
    Boolean,
}

/// Postgres backend. **Specified, not yet wired at runtime.**
///
/// The M0–M4 pool is still `sqlx::SqlitePool`; this struct exists so
/// the system check can flag field-type incompatibilities consistently
/// today, and so the M5 migration engine can render Postgres DDL ahead
/// of the runtime wiring. Switching the live pool happens when a real
/// user lands with a Postgres workload (deferred backlog entry).
#[derive(Debug)]
pub struct PostgresBackend;

/// SQLite backend. The umbral runtime default through M3.
#[derive(Debug)]
pub struct SqliteBackend;

// =========================================================================
// Trait impls — methods filled in by the M4 fan-out subagent A.
// =========================================================================

impl DatabaseBackend for PostgresBackend {
    fn name(&self) -> &'static str {
        "postgres"
    }

    /// Postgres feature catalogue. Source of truth: spec
    /// `docs/specs/05-backends-and-system-check.md` §7.1. Postgres carries
    /// every `BackendFeature` umbral reasons about today; `HStoreColumns`
    /// is reported true and the HSTORE extension stays a DBA concern.
    fn supports(&self, feature: BackendFeature) -> bool {
        match feature {
            BackendFeature::InsertReturning
            | BackendFeature::UpsertOnConflict
            | BackendFeature::ArrayColumns
            | BackendFeature::HStoreColumns
            | BackendFeature::JsonbColumns
            | BackendFeature::FullTextSearch
            | BackendFeature::CidrInet
            | BackendFeature::UuidNative
            | BackendFeature::Boolean => true,
        }
    }

    /// Postgres lifts `Text + max_length = N` to `VARCHAR(N)` so the
    /// length cap is enforced at the database level. `Text` without
    /// `max_length` stays `TEXT` (unbounded). SQLite ignores the
    /// length entirely — `VARCHAR(N)` and `TEXT` carry the same
    /// affinity there — so its `map_column` keeps the default impl.
    fn map_column(&self, col: &crate::migrate::Column) -> sea_query::ColumnType {
        use crate::orm::SqlType;
        use sea_query::ColumnType;
        // gaps3 #35: a `#[umbral(case_insensitive)]` text column becomes
        // `citext` — the whole-column case-insensitive type (comparisons,
        // UNIQUE, lookups all fold case while storage preserves the original).
        // The migration also emits `CREATE EXTENSION IF NOT EXISTS citext`.
        // Takes precedence over the VARCHAR(n) length mapping: citext is
        // unbounded (the `max_length` cap is a display hint, not storage).
        if matches!(col.ty, SqlType::Text) && col.case_insensitive {
            return ColumnType::custom("citext");
        }
        if matches!(col.ty, SqlType::Text) && col.max_length > 0 {
            return ColumnType::String(sea_query::StringLen::N(col.max_length));
        }
        self.map_type(col.ty)
    }

    /// Postgres `SqlType` -> `sea_query::ColumnType` mapping. Source of
    /// truth: spec `05-backends-and-system-check.md` §7.1.
    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType {
        use crate::orm::SqlType;
        use sea_query::ColumnType;
        match ty {
            SqlType::SmallInt => ColumnType::SmallInteger,
            SqlType::Integer => ColumnType::Integer,
            SqlType::BigInt => ColumnType::BigInteger,
            SqlType::Real => ColumnType::Float,
            SqlType::Double => ColumnType::Double,
            SqlType::Boolean => ColumnType::Boolean,
            SqlType::Text => ColumnType::Text,
            SqlType::Date => ColumnType::Date,
            SqlType::Time => ColumnType::Time,
            SqlType::Timestamptz => ColumnType::TimestampWithTimeZone,
            SqlType::Uuid => ColumnType::Uuid,
            // Postgres has both `json` and `jsonb`; we always pick `jsonb`
            // because that's the variant with index support and the
            // operator surface (`@>`, `->`, `->>`). The performance gap
            // vs `json` is meaningful for any real workload; the storage
            // overhead is negligible.
            SqlType::Json => ColumnType::JsonBinary,
            // Postgres array. The inner type round-trips through this
            // same map_type recursively (lifting ArrayElement to its
            // SqlType equivalent), which keeps the per-element rendering
            // in one place and lets future SqlType variants pick up
            // array support automatically once they're added to
            // ArrayElement.
            SqlType::Array(elem) => {
                ColumnType::Array(std::sync::Arc::new(self.map_type(elem.to_sql_type())))
            }
            SqlType::Inet => ColumnType::Inet,
            SqlType::Cidr => ColumnType::Cidr,
            SqlType::MacAddr => ColumnType::MacAddr,
            // sea-query has no built-in variant for these text-backed
            // Postgres types — render the native column type through
            // ColumnType::Custom. `bit varying` is the variable-length
            // bit string (v1 doesn't pin a width). gaps2 #70.
            SqlType::Xml => ColumnType::custom("xml"),
            SqlType::Ltree => ColumnType::custom("ltree"),
            SqlType::Bit => ColumnType::custom("bit varying"),
            // sea-query has no built-in `tsvector` variant — go through
            // ColumnType::Custom to render it. Populate via Postgres
            // trigger or GENERATED clause; umbral's migration engine
            // emits the bare column declaration.
            SqlType::FullText => ColumnType::custom("tsvector"),
            // ForeignKey is stored as BIGINT in the DB; the REFERENCES
            // clause is appended separately by the migration engine's
            // `build_column_def_*` helpers (sea-query doesn't have a
            // first-class FK DDL API at our version).
            SqlType::ForeignKey => ColumnType::BigInteger,
            // Postgres BYTEA. sea_query renders ColumnType::Blob as
            // `bytea` for Postgres and `blob` for SQLite, which is
            // exactly the dual we want.
            SqlType::Bytes => ColumnType::Blob,
            // BUG-10: NUMERIC(19, 4) — same shape on Postgres
            // (`NUMERIC(p, s)`) and SQLite (`NUMERIC` w/ affinity
            // inheriting precision via stored TEXT). v1 fixes the
            // dimensions; a future attribute lifts that.
            SqlType::Decimal => ColumnType::Decimal(Some((19, 4))),
        }
    }
}

impl DatabaseBackend for SqliteBackend {
    fn name(&self) -> &'static str {
        "sqlite"
    }

    /// SQLite feature catalogue. Source of truth: spec
    /// `docs/specs/05-backends-and-system-check.md` §7.1. SQLite carries
    /// the modern transactional features (RETURNING since 3.35, ON
    /// CONFLICT since 3.24) and native `BOOLEAN`, but no array / hstore /
    /// jsonb / full-text / network / native-UUID surface. UUIDs go
    /// through `TEXT` instead; see `map_type` below.
    fn supports(&self, feature: BackendFeature) -> bool {
        match feature {
            BackendFeature::InsertReturning
            | BackendFeature::UpsertOnConflict
            | BackendFeature::Boolean => true,
            BackendFeature::ArrayColumns
            | BackendFeature::HStoreColumns
            | BackendFeature::JsonbColumns
            | BackendFeature::FullTextSearch
            | BackendFeature::CidrInet
            | BackendFeature::UuidNative => false,
        }
    }

    /// SQLite `SqlType` -> `sea_query::ColumnType` mapping. Source of
    /// truth: spec `05-backends-and-system-check.md` §7.1. `Uuid` lands
    /// on `Text` because SQLite has no native UUID type, which is the
    /// reason `supports(UuidNative)` reports false above.
    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType {
        use crate::orm::SqlType;
        use sea_query::ColumnType;
        match ty {
            SqlType::SmallInt => ColumnType::SmallInteger,
            SqlType::Integer => ColumnType::Integer,
            SqlType::BigInt => ColumnType::BigInteger,
            SqlType::Real => ColumnType::Float,
            SqlType::Double => ColumnType::Double,
            SqlType::Boolean => ColumnType::Boolean,
            SqlType::Text => ColumnType::Text,
            SqlType::Date => ColumnType::Date,
            SqlType::Time => ColumnType::Time,
            SqlType::Timestamptz => ColumnType::TimestampWithTimeZone,
            // BLOB, not TEXT (gaps3 #80). sqlx encodes a `Uuid` as its 16 raw bytes on
            // SQLite, and its decoder reads ONLY those bytes back — hand it the 36-char
            // hyphenated text and it fails with `ParseByteLength { len: 36 }`. So the
            // value in the column is a blob whatever we call it, and calling it TEXT was
            // simply a lie: `CAST(id AS TEXT)` returned mojibake and anyone reading the
            // schema was misinformed.
            //
            // Declaring BLOB changes no data — the rows already hold blobs — and SQLite's
            // affinity rules never converted them anyway. The alternative (store the text
            // and match the old declaration) would break every typed read, because
            // `#[derive(FromRow)]` decodes a `Uuid` field through sqlx.
            SqlType::Uuid => ColumnType::Blob,
            // ForeignKey stored as BIGINT; the REFERENCES clause is
            // appended by the migration engine separately.
            SqlType::ForeignKey => ColumnType::BigInteger,
            // SQLite has no native JSON column type — the JSON1 extension
            // operates on TEXT values. Storing the document as TEXT keeps
            // the round-trip portable through sqlx's `json` feature (which
            // serializes `serde_json::Value` to a JSON string and decodes
            // back). Future work: add a JSON1 system check so JSON
            // operators on SQLite fail at boot when the extension isn't
            // compiled in (rare but possible on bare-builds).
            SqlType::Json => ColumnType::Text,
            // Postgres-only. The M4 `field.backend` system check fires
            // at boot when an Array field is registered against SQLite,
            // so reaching this arm at runtime means the boot path was
            // bypassed (low-level test seeding, hand-rolled
            // backend::init, etc.). Panic with a clear pointer rather
            // than rendering a SQL fragment SQLite can't parse.
            SqlType::Array(_) => panic!(
                "umbral::backend::SqliteBackend::map_type: SqlType::Array is Postgres-only. \
                 The field.backend system check should have failed boot; if you reached this \
                 panic, either the model registry wasn't initialised before map_type ran or \
                 the check was disabled. For portable list storage, use SqlType::Json instead."
            ),
            // Postgres-only network address types. field.backend gates
            // these at boot; reaching the SQLite map_type means the
            // boot path was bypassed.
            SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => panic!(
                "umbral::backend::SqliteBackend::map_type: SqlType::Inet/Cidr/MacAddr are \
                 Postgres-only. The field.backend system check should have failed boot."
            ),
            // gaps2 #70 — text-backed Postgres types are equally
            // Postgres-only; the field.backend check gates them at boot.
            SqlType::Xml | SqlType::Ltree | SqlType::Bit => panic!(
                "umbral::backend::SqliteBackend::map_type: SqlType::Xml/Ltree/Bit are \
                 Postgres-only. The field.backend system check should have failed boot."
            ),
            SqlType::FullText => panic!(
                "umbral::backend::SqliteBackend::map_type: SqlType::FullText is Postgres-only. \
                 The field.backend system check should have failed boot."
            ),
            // SQLite BLOB. sea_query renders ColumnType::Blob as the
            // dialect's right keyword (`blob` here, `bytea` for PG).
            SqlType::Bytes => ColumnType::Blob,
            // BUG-10: Decimal is Postgres-only at v1 (sqlx's
            // `rust_decimal` Encode/Decode doesn't ship a SQLite
            // implementation). The field.backend system check
            // should have failed boot before this map runs.
            SqlType::Decimal => panic!(
                "umbral::backend::SqliteBackend::map_type: SqlType::Decimal is Postgres-only. \
                 The field.backend system check should have failed boot."
            ),
        }
    }
}

// =========================================================================
// Ambient registration. The active backend is published into a process-
// wide `OnceLock` by `AppBuilder::build()`, alongside the pool and the
// settings. Mirrors the pattern from `crate::db` and `crate::settings`.
// =========================================================================

static ACTIVE: OnceLock<&'static dyn DatabaseBackend> = OnceLock::new();

/// Initialize the ambient backend. Called by `AppBuilder::build()` only.
pub(crate) fn init(backend: &'static dyn DatabaseBackend) {
    ACTIVE
        .set(backend)
        .expect("umbral::backend::init called more than once");
}

/// Return the active backend.
///
/// # Panics
///
/// Panics if `App::build()` hasn't run.
pub fn active() -> &'static dyn DatabaseBackend {
    *ACTIVE
        .get()
        .expect("umbral: backend not initialised — did you call App::build()?")
}

/// Detect the right backend for the given database URL by scheme.
///
/// Used by `AppBuilder::build()` to publish the ambient backend before
/// the system check runs. URLs that name an unshipped backend (mysql,
/// oracle) fail at boot with a clear error rather than continuing into
/// the system check phase.
pub fn detect(url: &str) -> Result<&'static dyn DatabaseBackend, BackendDetectError> {
    let scheme = url
        .split("://")
        .next()
        .and_then(|s| s.split(':').next())
        .unwrap_or(url);
    match scheme {
        "sqlite" => Ok(&SqliteBackend),
        "postgres" | "postgresql" => Ok(&PostgresBackend),
        other => Err(BackendDetectError::Unsupported(other.to_owned())),
    }
}

/// Error returned by `detect` when the URL scheme names an unshipped
/// backend.
#[derive(Debug)]
pub enum BackendDetectError {
    /// The URL scheme is one umbral hasn't implemented yet (mysql, oracle).
    Unsupported(String),
}

impl std::fmt::Display for BackendDetectError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BackendDetectError::Unsupported(scheme) => write!(
                f,
                "umbral: no backend shipped for URL scheme `{scheme}://`. \
                 M4 supports `sqlite://` and `postgres://`. \
                 MySQL, Oracle, and other backends are in the deferred backlog."
            ),
        }
    }
}

impl std::error::Error for BackendDetectError {}