use crate::db::pool::Pool;
use crate::error::AppError;
use crate::store::qualified_sys_table;
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TenantStrategy {
Database,
Rls,
}
impl std::str::FromStr for TenantStrategy {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"database" => Ok(TenantStrategy::Database),
"rls" => Ok(TenantStrategy::Rls),
_ => Err(AppError::BadRequest(format!(
"invalid tenant strategy: {} (expected database or rls)",
s
))),
}
}
}
#[derive(Clone, Debug)]
pub struct TenantEntry {
pub strategy: TenantStrategy,
pub database_url: Option<String>,
}
#[derive(Clone, Default)]
pub struct TenantRegistry {
by_id: HashMap<String, TenantEntry>,
}
impl TenantRegistry {
pub fn new() -> Self {
TenantRegistry {
by_id: HashMap::new(),
}
}
pub fn get(&self, tenant_id: &str) -> Option<&TenantEntry> {
self.by_id.get(tenant_id)
}
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
pub fn database_tenant_targets(&self) -> Vec<(String, String)> {
self.by_id
.iter()
.filter_map(|(id, entry)| {
if matches!(entry.strategy, TenantStrategy::Database) {
entry
.database_url
.as_ref()
.map(|url| (id.clone(), url.clone()))
} else {
None
}
})
.collect()
}
pub fn has_shared_rls_tenants(&self) -> bool {
self.by_id
.values()
.any(|e| matches!(e.strategy, TenantStrategy::Rls) && e.database_url.is_none())
}
pub fn rls_dedicated_db_targets(&self) -> Vec<(String, String)> {
self.by_id
.iter()
.filter_map(|(id, entry)| {
if matches!(entry.strategy, TenantStrategy::Rls) {
entry
.database_url
.as_ref()
.map(|url| (id.clone(), url.clone()))
} else {
None
}
})
.collect()
}
}
pub async fn load_registry_from_pool(pool: &Pool) -> Result<TenantRegistry, AppError> {
let q_table = qualified_sys_table("_sys_tenants");
let sql = format!(
"SELECT id, strategy, database_url FROM {} ORDER BY id",
q_table
);
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(&sql)
.fetch_all(pool)
.await?;
let mut by_id = HashMap::new();
for (id, strategy_str, database_url) in rows {
if strategy_str.eq_ignore_ascii_case("schema") {
tracing::warn!(
"tenant {}: strategy 'schema' is no longer supported, skipping",
id
);
continue;
}
let strategy: TenantStrategy = strategy_str.parse().map_err(|e: AppError| e)?;
if matches!(&strategy, TenantStrategy::Database)
&& database_url.as_ref().map(|s| s.is_empty()).unwrap_or(true)
{
tracing::warn!(
"tenant {}: strategy database requires database_url, skipping",
id
);
continue;
}
by_id.insert(
id,
TenantEntry {
strategy,
database_url: database_url.filter(|s| !s.is_empty()),
},
);
}
Ok(TenantRegistry { by_id })
}