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}