Skip to main content

umbral_core/db/
router.rs

1//! The swappable `DatabaseRouter` trait and its default implementation.
2//! See `docs/superpowers/specs/2026-06-16-database-router-foundation-design.md`.
3
4use std::sync::{Arc, OnceLock};
5
6use crate::db::route_context::RouteContext;
7use crate::migrate::ModelMeta;
8
9/// A database alias — the key under which a pool is registered
10/// (`App::builder().database(alias, pool)`), e.g. `"default"`, `"replica"`.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct Alias(String);
13
14impl Alias {
15    pub fn new(s: impl Into<String>) -> Self {
16        Alias(s.into())
17    }
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21    /// The conventional default alias.
22    pub fn default_alias() -> Self {
23        Alias("default".to_string())
24    }
25}
26
27impl From<&str> for Alias {
28    fn from(s: &str) -> Self {
29        Alias(s.to_string())
30    }
31}
32impl From<String> for Alias {
33    fn from(s: String) -> Self {
34        Alias(s)
35    }
36}
37
38/// A validated Postgres schema identifier. Constructed only through
39/// [`Schema::new`], which rejects anything that isn't a safe identifier,
40/// so a schema name can never be a SQL-injection vector — it is always
41/// emitted as a quoted identifier regardless.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Schema(String);
44
45impl Schema {
46    /// Validate and wrap a schema name: `^[A-Za-z_][A-Za-z0-9_]*$`, 1..=63 chars
47    /// (Postgres identifier limit). Returns `None` for anything else.
48    pub fn new(s: impl Into<String>) -> Option<Self> {
49        let s = s.into();
50        let ok = (1..=63).contains(&s.len())
51            && s.chars()
52                .next()
53                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
54            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
55        ok.then_some(Schema(s))
56    }
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60}
61
62/// The operation a route is being resolved for. The query terminal knows
63/// whether it is reading or writing; this is passed to the seam, not stored
64/// in the context.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum RouteOp {
67    Read,
68    Write,
69}
70
71/// Swappable routing policy. Every decision umbral makes about *which*
72/// database/relation/migration target, plus the optional per-request schema,
73/// flows through this trait. The default methods reproduce today's behavior;
74/// install a custom impl via `App::builder().router(MyRouter)`.
75pub trait DatabaseRouter: Send + Sync {
76    /// Alias of the database to read `model` from for this request.
77    fn db_for_read(&self, model: &ModelMeta, ctx: &RouteContext) -> Alias {
78        let _ = ctx;
79        default_alias_for(model)
80    }
81
82    /// Alias of the database to write `model` to for this request.
83    fn db_for_write(&self, model: &ModelMeta, ctx: &RouteContext) -> Alias {
84        let _ = ctx;
85        default_alias_for(model)
86    }
87
88    /// May a relation (FK) span these two models? Default: same alias only
89    /// (the #22 cross-DB FK guard).
90    fn allow_relation(&self, a: &ModelMeta, b: &ModelMeta) -> bool {
91        default_alias_for(a) == default_alias_for(b)
92    }
93
94    /// Should `model` be migrated on database `alias`? Default: yes when
95    /// `alias` is the model's assigned alias.
96    fn allow_migrate(&self, alias: &str, model: &ModelMeta) -> bool {
97        default_alias_for(model).as_str() == alias
98    }
99
100    /// The Postgres schema to scope this request's queries to. Default: None
101    /// (no qualification — today's behavior). `Some(schema)` makes the SQL
102    /// builder schema-qualify table references.
103    fn schema_for(&self, ctx: &RouteContext) -> Option<Schema> {
104        let _ = ctx;
105        None
106    }
107
108    /// The Postgres schema to scope queries against a specific `table` to, for
109    /// this request. Default: delegates to [`schema_for`](Self::schema_for)
110    /// (the per-request schema, table-agnostic). Override to vary the schema
111    /// **per table** — the seam schema-per-tenant needs so a SHARED_APPS table
112    /// (the `Tenant` registry, auth, etc.) stays in `public` (`None`) while a
113    /// tenant-owned table routes to the active tenant's schema. `table` is the
114    /// bare SQL table name (`ModelMeta::table`).
115    fn schema_for_table(&self, ctx: &RouteContext, table: &str) -> Option<Schema> {
116        let _ = table;
117        self.schema_for(ctx)
118    }
119}
120
121/// Today's static precedence, resolved by name: per-model `Model::DATABASE`
122/// then per-plugin `Plugin::database()` (both folded into `MODEL_ALIASES` at
123/// build) then `"default"`. This is exactly what the old `resolve_pool` did.
124fn default_alias_for(model: &ModelMeta) -> Alias {
125    match crate::migrate::model_alias(&model.name) {
126        Some(a) => Alias::new(a),
127        None => Alias::default_alias(),
128    }
129}
130
131/// The zero-override router. Every method is the trait default.
132#[derive(Debug, Default)]
133pub struct DefaultRouter;
134
135impl DatabaseRouter for DefaultRouter {}
136
137static ROUTER: OnceLock<Arc<dyn DatabaseRouter>> = OnceLock::new();
138static DEFAULT: OnceLock<Arc<dyn DatabaseRouter>> = OnceLock::new();
139
140/// Install the app's router. Called once during `App::build`. Idempotent
141/// no-op on a second call (so tests that build twice don't blow up).
142pub(crate) fn install_router(router: Arc<dyn DatabaseRouter>) {
143    let _ = ROUTER.set(router);
144}
145
146/// Public seam for a **plugin** to install its [`DatabaseRouter`] from
147/// `Plugin::on_ready`, when the router is plugin-owned rather than wired by the
148/// app via `App::builder().router(...)`. The schema-per-tenant `TenantsPlugin`
149/// is the motivating case: it builds its `TenantRouter` from per-build config
150/// (the SHARED_APPS table set) and installs it itself, so the consumer's
151/// `main.rs` only writes `.plugin(TenantsPlugin::new()...)`.
152///
153/// Idempotent first-write-wins, exactly like [`install_router`]: an explicit
154/// `App::builder().router(...)` (installed during `build()`, before `on_ready`)
155/// therefore takes precedence over a plugin's. A plugin that needs to be sure
156/// it owns routing simply documents "don't also call `.router(...)`".
157pub fn install_router_from_plugin(router: Arc<dyn DatabaseRouter>) {
158    let _ = ROUTER.set(router);
159}
160
161fn default_router_arc() -> Arc<dyn DatabaseRouter> {
162    DEFAULT.get_or_init(|| Arc::new(DefaultRouter)).clone()
163}
164
165/// The ambient router: the installed one, or `DefaultRouter` before/without
166/// `App::build` (boot, CLI, low-level tests).
167pub fn router() -> Arc<dyn DatabaseRouter> {
168    ROUTER.get().cloned().unwrap_or_else(default_router_arc)
169}
170
171/// Build a sea-query table reference, schema-qualified when the active router
172/// yields a schema for the current request. When `schema_for` is `None` (the
173/// default), returns the bare table — byte-identical to today's SQL, so the
174/// whole existing suite stays green under `DefaultRouter`.
175///
176/// This is the SQL-level seam for option-C schema-per-tenant: every FROM/JOIN
177/// table position in the ORM routes through here, so a router that returns
178/// `Some("tenant_7")` makes generated SQL read `"tenant_7"."post"` with zero
179/// extra round-trips (no `SET search_path`). SQLite has no schemas, so a router
180/// must return `None` from `schema_for` for any SQLite-bound request. This
181/// helper is backend-agnostic — it does NOT detect SQLite — so a router that
182/// wrongly returns `Some` on SQLite emits a schema-qualified ref that SQLite
183/// rejects at execution. A backend-aware warn-and-skip is a Phase-2 follow-up
184/// (see the design spec); today the contract is "no schema router on SQLite".
185pub fn schema_qualified_table(table: &str) -> sea_query::TableRef {
186    use sea_query::{Alias as SqAlias, IntoTableRef};
187    let ctx = crate::db::route_context::current();
188    match router().schema_for_table(&ctx, table) {
189        Some(schema) => (SqAlias::new(schema.as_str()), SqAlias::new(table)).into_table_ref(),
190        None => SqAlias::new(table).into_table_ref(),
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn schema_accepts_valid_identifiers_and_rejects_the_rest() {
200        assert!(Schema::new("tenant_7").is_some());
201        assert!(Schema::new("_private").is_some());
202        assert!(Schema::new("public").is_some());
203        // rejects injection / malformed
204        assert!(Schema::new("").is_none());
205        assert!(Schema::new("1tenant").is_none());
206        assert!(Schema::new("a b").is_none());
207        assert!(Schema::new("drop\";--").is_none());
208        assert!(Schema::new("a".repeat(64)).is_none());
209    }
210
211    #[test]
212    fn alias_roundtrips() {
213        assert_eq!(Alias::from("replica").as_str(), "replica");
214        assert_eq!(Alias::default_alias().as_str(), "default");
215    }
216
217    #[test]
218    fn schema_qualified_table_is_bare_under_default_router() {
219        // Default router => schema_for None => bare table, byte-identical to
220        // today's SQL. No installed router (ROUTER OnceLock empty) here, so
221        // `router()` falls back to `DefaultRouter`.
222        let sql = sea_query::Query::select()
223            .column(sea_query::Asterisk)
224            .from(schema_qualified_table("widget"))
225            .to_string(sea_query::PostgresQueryBuilder);
226        assert!(sql.contains("\"widget\""), "got: {sql}");
227        // NOT schema-dot-qualified: no `"<schema>"."widget"` form.
228        assert!(
229            !sql.contains(".\"widget\""),
230            "unexpected qualification: {sql}"
231        );
232    }
233}