Skip to main content

umbral_core/
backend.rs

1//! The database backend abstraction.
2//!
3//! `DatabaseBackend` is the seam where dialect differences live. The
4//! trait sits on top of sea-query (which already abstracts dialect
5//! rendering) and sqlx (which abstracts drivers); umbral adds the
6//! umbral-specific reasoning layer on top so the system check (`check`)
7//! and the migration engine (M5, `06-migration-engine.md`) can ask the
8//! same questions of every backend.
9//!
10//! M4 ships two backends:
11//!
12//! - [`SqliteBackend`] — the runtime default. SQLite is what the M0–M3
13//!   pool already opens; this just gives it a queryable identity in the
14//!   check phase.
15//! - [`PostgresBackend`] — declared and queryable for compatibility
16//!   checks, but the umbral pool is still `sqlx::SqlitePool` at M4. The
17//!   real `sqlx::PgPool` wiring lands when there's a real user need;
18//!   the trait is in place so M5's migration engine can render Postgres
19//!   DDL today and run it tomorrow.
20//!
21//! `MySqlBackend`, `OracleBackend`, and friends stay in the deferred
22//! backlog per PRD §14.
23//!
24//! See `docs/specs/05-backends-and-system-check.md` for the target
25//! design and the rationale for each `BackendFeature` variant.
26
27use std::sync::OnceLock;
28
29/// One umbral-supported relational backend.
30///
31/// Trait surface kept narrow at M4: identity (`name`), feature queries
32/// (`supports`), and SQL-type mapping for the migration engine
33/// (`map_type`). `quote_identifier`, `render_upsert`, and dialect-
34/// specific rendering helpers get added when M5's migration engine and
35/// bulk-insert paths need them; sea-query exposes those via per-backend
36/// `QueryBuilder` types rather than a single dialect enum, so umbral
37/// dispatches through `name()` for now and adds typed rendering helpers
38/// when there's a real consumer.
39pub trait DatabaseBackend: std::fmt::Debug + Send + Sync + 'static {
40    /// Stable string identifier. `"postgres"`, `"sqlite"`, etc. Used as
41    /// the matching key in `FieldSpec::supported_backends`, and shown
42    /// in system-check error messages.
43    fn name(&self) -> &'static str;
44
45    /// Whether this backend supports the given feature. Used by the
46    /// system check to gate Postgres-only field types (Array, HStore,
47    /// jsonb) and by the migration engine to choose between
48    /// `INSERT ... RETURNING` and `INSERT; last_insert_rowid()`.
49    fn supports(&self, feature: BackendFeature) -> bool;
50
51    /// Map an umbral `SqlType` to the sea-query `ColumnType` that
52    /// renders the right native SQL column type on this backend. The
53    /// migration engine (M5) reads this when generating `CREATE TABLE`.
54    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType;
55
56    /// Map a full column (type + per-column hints like `max_length`)
57    /// to its sea-query `ColumnType`. Default impl delegates to
58    /// `map_type` — backends that want to lift hints (Postgres
59    /// rendering `Text + max_length=N` as `VARCHAR(N)`, for example)
60    /// override this. The migration engine prefers this over
61    /// `map_type` so the per-column attributes flow into DDL.
62    fn map_column(&self, col: &crate::migrate::Column) -> sea_query::ColumnType {
63        self.map_type(col.ty)
64    }
65}
66
67/// Backend feature flags surfaced to umbral.
68///
69/// New variants land alongside new backend behaviour. Each variant
70/// represents one capability that umbral reasons about explicitly; the
71/// system check or the migration engine asks via `supports(feature)`
72/// rather than hard-coding `if backend.name() == "postgres"`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum BackendFeature {
75    /// `INSERT ... RETURNING column[, ...]` on inserts. Postgres + SQLite
76    /// (3.35+); MySQL doesn't have it natively.
77    InsertReturning,
78    /// `INSERT ... ON CONFLICT (col) DO UPDATE` upserts. Postgres + SQLite.
79    UpsertOnConflict,
80    /// Array column types (`text[]`, `int[]`, etc.). Postgres only.
81    ArrayColumns,
82    /// `HStoreField` analogue: `key => value` text maps. Postgres only.
83    HStoreColumns,
84    /// Native `jsonb` column with index / operator support. Postgres only;
85    /// SQLite supports JSON-as-TEXT but without the operator surface, so
86    /// this flag is more honest as "real jsonb" than "any JSON."
87    JsonbColumns,
88    /// Native full-text search (`tsvector` + `to_tsquery`). Postgres only.
89    FullTextSearch,
90    /// CIDR / INET / MACADDR network address column types. Postgres only.
91    CidrInet,
92    /// Native `UUID` column type. Postgres only. SQLite has no uuid type: sqlx encodes a
93    /// `Uuid` as its 16 raw bytes there, so umbral declares the column `BLOB` (gaps3 #80).
94    UuidNative,
95    /// Native `BOOLEAN` column type. Postgres + SQLite (since 3.23); MySQL
96    /// historically encodes as TINYINT.
97    Boolean,
98}
99
100/// Postgres backend. **Specified, not yet wired at runtime.**
101///
102/// The M0–M4 pool is still `sqlx::SqlitePool`; this struct exists so
103/// the system check can flag field-type incompatibilities consistently
104/// today, and so the M5 migration engine can render Postgres DDL ahead
105/// of the runtime wiring. Switching the live pool happens when a real
106/// user lands with a Postgres workload (deferred backlog entry).
107#[derive(Debug)]
108pub struct PostgresBackend;
109
110/// SQLite backend. The umbral runtime default through M3.
111#[derive(Debug)]
112pub struct SqliteBackend;
113
114// =========================================================================
115// Trait impls — methods filled in by the M4 fan-out subagent A.
116// =========================================================================
117
118impl DatabaseBackend for PostgresBackend {
119    fn name(&self) -> &'static str {
120        "postgres"
121    }
122
123    /// Postgres feature catalogue. Source of truth: spec
124    /// `docs/specs/05-backends-and-system-check.md` §7.1. Postgres carries
125    /// every `BackendFeature` umbral reasons about today; `HStoreColumns`
126    /// is reported true and the HSTORE extension stays a DBA concern.
127    fn supports(&self, feature: BackendFeature) -> bool {
128        match feature {
129            BackendFeature::InsertReturning
130            | BackendFeature::UpsertOnConflict
131            | BackendFeature::ArrayColumns
132            | BackendFeature::HStoreColumns
133            | BackendFeature::JsonbColumns
134            | BackendFeature::FullTextSearch
135            | BackendFeature::CidrInet
136            | BackendFeature::UuidNative
137            | BackendFeature::Boolean => true,
138        }
139    }
140
141    /// Postgres lifts `Text + max_length = N` to `VARCHAR(N)` so the
142    /// length cap is enforced at the database level. `Text` without
143    /// `max_length` stays `TEXT` (unbounded). SQLite ignores the
144    /// length entirely — `VARCHAR(N)` and `TEXT` carry the same
145    /// affinity there — so its `map_column` keeps the default impl.
146    fn map_column(&self, col: &crate::migrate::Column) -> sea_query::ColumnType {
147        use crate::orm::SqlType;
148        use sea_query::ColumnType;
149        // gaps3 #35: a `#[umbral(case_insensitive)]` text column becomes
150        // `citext` — the whole-column case-insensitive type (comparisons,
151        // UNIQUE, lookups all fold case while storage preserves the original).
152        // The migration also emits `CREATE EXTENSION IF NOT EXISTS citext`.
153        // Takes precedence over the VARCHAR(n) length mapping: citext is
154        // unbounded (the `max_length` cap is a display hint, not storage).
155        if matches!(col.ty, SqlType::Text) && col.case_insensitive {
156            return ColumnType::custom("citext");
157        }
158        if matches!(col.ty, SqlType::Text) && col.max_length > 0 {
159            return ColumnType::String(sea_query::StringLen::N(col.max_length));
160        }
161        self.map_type(col.ty)
162    }
163
164    /// Postgres `SqlType` -> `sea_query::ColumnType` mapping. Source of
165    /// truth: spec `05-backends-and-system-check.md` §7.1.
166    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType {
167        use crate::orm::SqlType;
168        use sea_query::ColumnType;
169        match ty {
170            SqlType::SmallInt => ColumnType::SmallInteger,
171            SqlType::Integer => ColumnType::Integer,
172            SqlType::BigInt => ColumnType::BigInteger,
173            SqlType::Real => ColumnType::Float,
174            SqlType::Double => ColumnType::Double,
175            SqlType::Boolean => ColumnType::Boolean,
176            SqlType::Text => ColumnType::Text,
177            SqlType::Date => ColumnType::Date,
178            SqlType::Time => ColumnType::Time,
179            SqlType::Timestamptz => ColumnType::TimestampWithTimeZone,
180            SqlType::Timestamp => ColumnType::Timestamp,
181            SqlType::Uuid => ColumnType::Uuid,
182            // Postgres has both `json` and `jsonb`; we always pick `jsonb`
183            // because that's the variant with index support and the
184            // operator surface (`@>`, `->`, `->>`). The performance gap
185            // vs `json` is meaningful for any real workload; the storage
186            // overhead is negligible.
187            SqlType::Json => ColumnType::JsonBinary,
188            // Postgres array. The inner type round-trips through this
189            // same map_type recursively (lifting ArrayElement to its
190            // SqlType equivalent), which keeps the per-element rendering
191            // in one place and lets future SqlType variants pick up
192            // array support automatically once they're added to
193            // ArrayElement.
194            SqlType::Array(elem) => {
195                ColumnType::Array(std::sync::Arc::new(self.map_type(elem.to_sql_type())))
196            }
197            SqlType::Inet => ColumnType::Inet,
198            SqlType::Cidr => ColumnType::Cidr,
199            SqlType::MacAddr => ColumnType::MacAddr,
200            // sea-query has no built-in variant for these text-backed
201            // Postgres types — render the native column type through
202            // ColumnType::Custom. `bit varying` is the variable-length
203            // bit string (v1 doesn't pin a width). gaps2 #70.
204            SqlType::Xml => ColumnType::custom("xml"),
205            SqlType::Ltree => ColumnType::custom("ltree"),
206            SqlType::Bit => ColumnType::custom("bit varying"),
207            // sea-query has no built-in `tsvector` variant — go through
208            // ColumnType::Custom to render it. Populate via Postgres
209            // trigger or GENERATED clause; umbral's migration engine
210            // emits the bare column declaration.
211            SqlType::FullText => ColumnType::custom("tsvector"),
212            // ForeignKey is stored as BIGINT in the DB; the REFERENCES
213            // clause is appended separately by the migration engine's
214            // `build_column_def_*` helpers (sea-query doesn't have a
215            // first-class FK DDL API at our version).
216            SqlType::ForeignKey => ColumnType::BigInteger,
217            // Postgres BYTEA. sea_query renders ColumnType::Blob as
218            // `bytea` for Postgres and `blob` for SQLite, which is
219            // exactly the dual we want.
220            SqlType::Bytes => ColumnType::Blob,
221            // BUG-10: NUMERIC(19, 4) — same shape on Postgres
222            // (`NUMERIC(p, s)`) and SQLite (`NUMERIC` w/ affinity
223            // inheriting precision via stored TEXT). v1 fixes the
224            // dimensions; a future attribute lifts that.
225            SqlType::Decimal => ColumnType::Decimal(Some((19, 4))),
226            // Caller-chosen `numeric(precision, scale)` dimensions.
227            SqlType::DecimalN(spec) => {
228                ColumnType::Decimal(Some((spec.precision.into(), spec.scale.into())))
229            }
230            // Arbitrary-precision `numeric` — no precision/scale, so it stores
231            // as many digits as the value carries. This is the whole point of
232            // BigDecimal over the fixed-dimension `Decimal` above.
233            SqlType::BigDecimal => ColumnType::Decimal(None),
234            // PostGIS spatial columns render through `custom`, the same escape
235            // hatch `Xml` / `Ltree` / `FullText` use — sea-query has no native
236            // `geometry(Point, 4326)` ColumnType. SRID/kind travel in the spec.
237            SqlType::Geometry(spec) => ColumnType::custom(pg_spatial_type("geometry", spec)),
238            SqlType::Geography(spec) => ColumnType::custom(pg_spatial_type("geography", spec)),
239        }
240    }
241}
242
243/// Render a PostGIS spatial type modifier: `geometry(Point,4326)`. The bare
244/// base type (`geometry` / `geography`) is emitted when the column is
245/// unconstrained (`kind == Geometry` and `srid == 0`), matching how PostGIS
246/// itself omits the typmod for an unconstrained column.
247pub(crate) fn pg_spatial_type(base: &str, spec: crate::orm::GeometrySpec) -> String {
248    use crate::orm::GeometryKind;
249    match (spec.kind, spec.srid) {
250        (GeometryKind::Geometry, 0) => base.to_string(),
251        (GeometryKind::Geometry, srid) => format!("{base}(Geometry,{srid})"),
252        (kind, 0) => format!("{base}({})", kind.pg_modifier()),
253        (kind, srid) => format!("{base}({},{srid})", kind.pg_modifier()),
254    }
255}
256
257impl DatabaseBackend for SqliteBackend {
258    fn name(&self) -> &'static str {
259        "sqlite"
260    }
261
262    /// SQLite feature catalogue. Source of truth: spec
263    /// `docs/specs/05-backends-and-system-check.md` §7.1. SQLite carries
264    /// the modern transactional features (RETURNING since 3.35, ON
265    /// CONFLICT since 3.24) and native `BOOLEAN`, but no array / hstore /
266    /// jsonb / full-text / network / native-UUID surface. UUIDs go
267    /// through `TEXT` instead; see `map_type` below.
268    fn supports(&self, feature: BackendFeature) -> bool {
269        match feature {
270            BackendFeature::InsertReturning
271            | BackendFeature::UpsertOnConflict
272            | BackendFeature::Boolean => true,
273            BackendFeature::ArrayColumns
274            | BackendFeature::HStoreColumns
275            | BackendFeature::JsonbColumns
276            | BackendFeature::FullTextSearch
277            | BackendFeature::CidrInet
278            | BackendFeature::UuidNative => false,
279        }
280    }
281
282    /// SQLite `SqlType` -> `sea_query::ColumnType` mapping. Source of
283    /// truth: spec `05-backends-and-system-check.md` §7.1. `Uuid` lands
284    /// on `Text` because SQLite has no native UUID type, which is the
285    /// reason `supports(UuidNative)` reports false above.
286    fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType {
287        use crate::orm::SqlType;
288        use sea_query::ColumnType;
289        match ty {
290            SqlType::SmallInt => ColumnType::SmallInteger,
291            SqlType::Integer => ColumnType::Integer,
292            SqlType::BigInt => ColumnType::BigInteger,
293            SqlType::Real => ColumnType::Float,
294            SqlType::Double => ColumnType::Double,
295            SqlType::Boolean => ColumnType::Boolean,
296            SqlType::Text => ColumnType::Text,
297            SqlType::Date => ColumnType::Date,
298            SqlType::Time => ColumnType::Time,
299            SqlType::Timestamptz => ColumnType::TimestampWithTimeZone,
300            SqlType::Timestamp => ColumnType::Timestamp,
301            // BLOB, not TEXT (gaps3 #80). sqlx encodes a `Uuid` as its 16 raw bytes on
302            // SQLite, and its decoder reads ONLY those bytes back — hand it the 36-char
303            // hyphenated text and it fails with `ParseByteLength { len: 36 }`. So the
304            // value in the column is a blob whatever we call it, and calling it TEXT was
305            // simply a lie: `CAST(id AS TEXT)` returned mojibake and anyone reading the
306            // schema was misinformed.
307            //
308            // Declaring BLOB changes no data — the rows already hold blobs — and SQLite's
309            // affinity rules never converted them anyway. The alternative (store the text
310            // and match the old declaration) would break every typed read, because
311            // `#[derive(FromRow)]` decodes a `Uuid` field through sqlx.
312            SqlType::Uuid => ColumnType::Blob,
313            // ForeignKey stored as BIGINT; the REFERENCES clause is
314            // appended by the migration engine separately.
315            SqlType::ForeignKey => ColumnType::BigInteger,
316            // SQLite has no native JSON column type — the JSON1 extension
317            // operates on TEXT values. Storing the document as TEXT keeps
318            // the round-trip portable through sqlx's `json` feature (which
319            // serializes `serde_json::Value` to a JSON string and decodes
320            // back). Future work: add a JSON1 system check so JSON
321            // operators on SQLite fail at boot when the extension isn't
322            // compiled in (rare but possible on bare-builds).
323            SqlType::Json => ColumnType::Text,
324            // Postgres-only. The M4 `field.backend` system check fires
325            // at boot when an Array field is registered against SQLite,
326            // so reaching this arm at runtime means the boot path was
327            // bypassed (low-level test seeding, hand-rolled
328            // backend::init, etc.). Panic with a clear pointer rather
329            // than rendering a SQL fragment SQLite can't parse.
330            SqlType::Array(_) => panic!(
331                "umbral::backend::SqliteBackend::map_type: SqlType::Array is Postgres-only. \
332                 The field.backend system check should have failed boot; if you reached this \
333                 panic, either the model registry wasn't initialised before map_type ran or \
334                 the check was disabled. For portable list storage, use SqlType::Json instead."
335            ),
336            // Postgres-only network address types. field.backend gates
337            // these at boot; reaching the SQLite map_type means the
338            // boot path was bypassed.
339            SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => panic!(
340                "umbral::backend::SqliteBackend::map_type: SqlType::Inet/Cidr/MacAddr are \
341                 Postgres-only. The field.backend system check should have failed boot."
342            ),
343            // gaps2 #70 — text-backed Postgres types are equally
344            // Postgres-only; the field.backend check gates them at boot.
345            SqlType::Xml | SqlType::Ltree | SqlType::Bit => panic!(
346                "umbral::backend::SqliteBackend::map_type: SqlType::Xml/Ltree/Bit are \
347                 Postgres-only. The field.backend system check should have failed boot."
348            ),
349            SqlType::FullText => panic!(
350                "umbral::backend::SqliteBackend::map_type: SqlType::FullText is Postgres-only. \
351                 The field.backend system check should have failed boot."
352            ),
353            // SQLite BLOB. sea_query renders ColumnType::Blob as the
354            // dialect's right keyword (`blob` here, `bytea` for PG).
355            SqlType::Bytes => ColumnType::Blob,
356            // BUG-10: Decimal is Postgres-only at v1 (sqlx's
357            // `rust_decimal` Encode/Decode doesn't ship a SQLite
358            // implementation). BigDecimal is Postgres-only for the same
359            // reason (no arbitrary-precision numeric on SQLite). The
360            // field.backend system check should have failed boot before
361            // this map runs.
362            // PostGIS geometry/geography are Postgres-only; SQLite has no
363            // spatial types. The field.backend check rejects them at boot.
364            SqlType::Geometry(_) | SqlType::Geography(_) => panic!(
365                "umbral::backend::SqliteBackend::map_type: SqlType::{ty:?} is Postgres-only \
366                 (PostGIS). The field.backend system check should have failed boot."
367            ),
368            SqlType::Decimal | SqlType::BigDecimal | SqlType::DecimalN(_) => panic!(
369                "umbral::backend::SqliteBackend::map_type: SqlType::{ty:?} is Postgres-only. \
370                 The field.backend system check should have failed boot."
371            ),
372        }
373    }
374}
375
376// =========================================================================
377// Ambient registration. The active backend is published into a process-
378// wide `OnceLock` by `AppBuilder::build()`, alongside the pool and the
379// settings. Mirrors the pattern from `crate::db` and `crate::settings`.
380// =========================================================================
381
382static ACTIVE: OnceLock<&'static dyn DatabaseBackend> = OnceLock::new();
383
384/// Initialize the ambient backend. Called by `AppBuilder::build()` only.
385pub(crate) fn init(backend: &'static dyn DatabaseBackend) {
386    ACTIVE
387        .set(backend)
388        .expect("umbral::backend::init called more than once");
389}
390
391/// Return the active backend.
392///
393/// # Panics
394///
395/// Panics if `App::build()` hasn't run.
396pub fn active() -> &'static dyn DatabaseBackend {
397    *ACTIVE
398        .get()
399        .expect("umbral: backend not initialised — did you call App::build()?")
400}
401
402/// Detect the right backend for the given database URL by scheme.
403///
404/// Used by `AppBuilder::build()` to publish the ambient backend before
405/// the system check runs. URLs that name an unshipped backend (mysql,
406/// oracle) fail at boot with a clear error rather than continuing into
407/// the system check phase.
408pub fn detect(url: &str) -> Result<&'static dyn DatabaseBackend, BackendDetectError> {
409    let scheme = url
410        .split("://")
411        .next()
412        .and_then(|s| s.split(':').next())
413        .unwrap_or(url);
414    match scheme {
415        "sqlite" => Ok(&SqliteBackend),
416        "postgres" | "postgresql" => Ok(&PostgresBackend),
417        other => Err(BackendDetectError::Unsupported(other.to_owned())),
418    }
419}
420
421/// Error returned by `detect` when the URL scheme names an unshipped
422/// backend.
423#[derive(Debug)]
424pub enum BackendDetectError {
425    /// The URL scheme is one umbral hasn't implemented yet (mysql, oracle).
426    Unsupported(String),
427}
428
429impl std::fmt::Display for BackendDetectError {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        match self {
432            BackendDetectError::Unsupported(scheme) => write!(
433                f,
434                "umbral: no backend shipped for URL scheme `{scheme}://`. \
435                 M4 supports `sqlite://` and `postgres://`. \
436                 MySQL, Oracle, and other backends are in the deferred backlog."
437            ),
438        }
439    }
440}
441
442impl std::error::Error for BackendDetectError {}