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 encodes UUIDs as
93 /// `TEXT` instead.
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::Uuid => ColumnType::Uuid,
181 // Postgres has both `json` and `jsonb`; we always pick `jsonb`
182 // because that's the variant with index support and the
183 // operator surface (`@>`, `->`, `->>`). The performance gap
184 // vs `json` is meaningful for any real workload; the storage
185 // overhead is negligible.
186 SqlType::Json => ColumnType::JsonBinary,
187 // Postgres array. The inner type round-trips through this
188 // same map_type recursively (lifting ArrayElement to its
189 // SqlType equivalent), which keeps the per-element rendering
190 // in one place and lets future SqlType variants pick up
191 // array support automatically once they're added to
192 // ArrayElement.
193 SqlType::Array(elem) => {
194 ColumnType::Array(std::sync::Arc::new(self.map_type(elem.to_sql_type())))
195 }
196 SqlType::Inet => ColumnType::Inet,
197 SqlType::Cidr => ColumnType::Cidr,
198 SqlType::MacAddr => ColumnType::MacAddr,
199 // sea-query has no built-in variant for these text-backed
200 // Postgres types — render the native column type through
201 // ColumnType::Custom. `bit varying` is the variable-length
202 // bit string (v1 doesn't pin a width). gaps2 #70.
203 SqlType::Xml => ColumnType::custom("xml"),
204 SqlType::Ltree => ColumnType::custom("ltree"),
205 SqlType::Bit => ColumnType::custom("bit varying"),
206 // sea-query has no built-in `tsvector` variant — go through
207 // ColumnType::Custom to render it. Populate via Postgres
208 // trigger or GENERATED clause; umbral's migration engine
209 // emits the bare column declaration.
210 SqlType::FullText => ColumnType::custom("tsvector"),
211 // ForeignKey is stored as BIGINT in the DB; the REFERENCES
212 // clause is appended separately by the migration engine's
213 // `build_column_def_*` helpers (sea-query doesn't have a
214 // first-class FK DDL API at our version).
215 SqlType::ForeignKey => ColumnType::BigInteger,
216 // Postgres BYTEA. sea_query renders ColumnType::Blob as
217 // `bytea` for Postgres and `blob` for SQLite, which is
218 // exactly the dual we want.
219 SqlType::Bytes => ColumnType::Blob,
220 // BUG-10: NUMERIC(19, 4) — same shape on Postgres
221 // (`NUMERIC(p, s)`) and SQLite (`NUMERIC` w/ affinity
222 // inheriting precision via stored TEXT). v1 fixes the
223 // dimensions; a future attribute lifts that.
224 SqlType::Decimal => ColumnType::Decimal(Some((19, 4))),
225 }
226 }
227}
228
229impl DatabaseBackend for SqliteBackend {
230 fn name(&self) -> &'static str {
231 "sqlite"
232 }
233
234 /// SQLite feature catalogue. Source of truth: spec
235 /// `docs/specs/05-backends-and-system-check.md` §7.1. SQLite carries
236 /// the modern transactional features (RETURNING since 3.35, ON
237 /// CONFLICT since 3.24) and native `BOOLEAN`, but no array / hstore /
238 /// jsonb / full-text / network / native-UUID surface. UUIDs go
239 /// through `TEXT` instead; see `map_type` below.
240 fn supports(&self, feature: BackendFeature) -> bool {
241 match feature {
242 BackendFeature::InsertReturning
243 | BackendFeature::UpsertOnConflict
244 | BackendFeature::Boolean => true,
245 BackendFeature::ArrayColumns
246 | BackendFeature::HStoreColumns
247 | BackendFeature::JsonbColumns
248 | BackendFeature::FullTextSearch
249 | BackendFeature::CidrInet
250 | BackendFeature::UuidNative => false,
251 }
252 }
253
254 /// SQLite `SqlType` -> `sea_query::ColumnType` mapping. Source of
255 /// truth: spec `05-backends-and-system-check.md` §7.1. `Uuid` lands
256 /// on `Text` because SQLite has no native UUID type, which is the
257 /// reason `supports(UuidNative)` reports false above.
258 fn map_type(&self, ty: crate::orm::SqlType) -> sea_query::ColumnType {
259 use crate::orm::SqlType;
260 use sea_query::ColumnType;
261 match ty {
262 SqlType::SmallInt => ColumnType::SmallInteger,
263 SqlType::Integer => ColumnType::Integer,
264 SqlType::BigInt => ColumnType::BigInteger,
265 SqlType::Real => ColumnType::Float,
266 SqlType::Double => ColumnType::Double,
267 SqlType::Boolean => ColumnType::Boolean,
268 SqlType::Text => ColumnType::Text,
269 SqlType::Date => ColumnType::Date,
270 SqlType::Time => ColumnType::Time,
271 SqlType::Timestamptz => ColumnType::TimestampWithTimeZone,
272 SqlType::Uuid => ColumnType::Text,
273 // ForeignKey stored as BIGINT; the REFERENCES clause is
274 // appended by the migration engine separately.
275 SqlType::ForeignKey => ColumnType::BigInteger,
276 // SQLite has no native JSON column type — the JSON1 extension
277 // operates on TEXT values. Storing the document as TEXT keeps
278 // the round-trip portable through sqlx's `json` feature (which
279 // serializes `serde_json::Value` to a JSON string and decodes
280 // back). Future work: add a JSON1 system check so JSON
281 // operators on SQLite fail at boot when the extension isn't
282 // compiled in (rare but possible on bare-builds).
283 SqlType::Json => ColumnType::Text,
284 // Postgres-only. The M4 `field.backend` system check fires
285 // at boot when an Array field is registered against SQLite,
286 // so reaching this arm at runtime means the boot path was
287 // bypassed (low-level test seeding, hand-rolled
288 // backend::init, etc.). Panic with a clear pointer rather
289 // than rendering a SQL fragment SQLite can't parse.
290 SqlType::Array(_) => panic!(
291 "umbral::backend::SqliteBackend::map_type: SqlType::Array is Postgres-only. \
292 The field.backend system check should have failed boot; if you reached this \
293 panic, either the model registry wasn't initialised before map_type ran or \
294 the check was disabled. For portable list storage, use SqlType::Json instead."
295 ),
296 // Postgres-only network address types. field.backend gates
297 // these at boot; reaching the SQLite map_type means the
298 // boot path was bypassed.
299 SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => panic!(
300 "umbral::backend::SqliteBackend::map_type: SqlType::Inet/Cidr/MacAddr are \
301 Postgres-only. The field.backend system check should have failed boot."
302 ),
303 // gaps2 #70 — text-backed Postgres types are equally
304 // Postgres-only; the field.backend check gates them at boot.
305 SqlType::Xml | SqlType::Ltree | SqlType::Bit => panic!(
306 "umbral::backend::SqliteBackend::map_type: SqlType::Xml/Ltree/Bit are \
307 Postgres-only. The field.backend system check should have failed boot."
308 ),
309 SqlType::FullText => panic!(
310 "umbral::backend::SqliteBackend::map_type: SqlType::FullText is Postgres-only. \
311 The field.backend system check should have failed boot."
312 ),
313 // SQLite BLOB. sea_query renders ColumnType::Blob as the
314 // dialect's right keyword (`blob` here, `bytea` for PG).
315 SqlType::Bytes => ColumnType::Blob,
316 // BUG-10: Decimal is Postgres-only at v1 (sqlx's
317 // `rust_decimal` Encode/Decode doesn't ship a SQLite
318 // implementation). The field.backend system check
319 // should have failed boot before this map runs.
320 SqlType::Decimal => panic!(
321 "umbral::backend::SqliteBackend::map_type: SqlType::Decimal is Postgres-only. \
322 The field.backend system check should have failed boot."
323 ),
324 }
325 }
326}
327
328// =========================================================================
329// Ambient registration. The active backend is published into a process-
330// wide `OnceLock` by `AppBuilder::build()`, alongside the pool and the
331// settings. Mirrors the pattern from `crate::db` and `crate::settings`.
332// =========================================================================
333
334static ACTIVE: OnceLock<&'static dyn DatabaseBackend> = OnceLock::new();
335
336/// Initialize the ambient backend. Called by `AppBuilder::build()` only.
337pub(crate) fn init(backend: &'static dyn DatabaseBackend) {
338 ACTIVE
339 .set(backend)
340 .expect("umbral::backend::init called more than once");
341}
342
343/// Return the active backend.
344///
345/// # Panics
346///
347/// Panics if `App::build()` hasn't run.
348pub fn active() -> &'static dyn DatabaseBackend {
349 *ACTIVE
350 .get()
351 .expect("umbral: backend not initialised — did you call App::build()?")
352}
353
354/// Detect the right backend for the given database URL by scheme.
355///
356/// Used by `AppBuilder::build()` to publish the ambient backend before
357/// the system check runs. URLs that name an unshipped backend (mysql,
358/// oracle) fail at boot with a clear error rather than continuing into
359/// the system check phase.
360pub fn detect(url: &str) -> Result<&'static dyn DatabaseBackend, BackendDetectError> {
361 let scheme = url
362 .split("://")
363 .next()
364 .and_then(|s| s.split(':').next())
365 .unwrap_or(url);
366 match scheme {
367 "sqlite" => Ok(&SqliteBackend),
368 "postgres" | "postgresql" => Ok(&PostgresBackend),
369 other => Err(BackendDetectError::Unsupported(other.to_owned())),
370 }
371}
372
373/// Error returned by `detect` when the URL scheme names an unshipped
374/// backend.
375#[derive(Debug)]
376pub enum BackendDetectError {
377 /// The URL scheme is one umbral hasn't implemented yet (mysql, oracle).
378 Unsupported(String),
379}
380
381impl std::fmt::Display for BackendDetectError {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 match self {
384 BackendDetectError::Unsupported(scheme) => write!(
385 f,
386 "umbral: no backend shipped for URL scheme `{scheme}://`. \
387 M4 supports `sqlite://` and `postgres://`. \
388 MySQL, Oracle, and other backends are in the deferred backlog."
389 ),
390 }
391 }
392}
393
394impl std::error::Error for BackendDetectError {}