use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::RwLock;
#[derive(Debug, Default)]
pub(crate) struct TenantRegistry {
tables: HashMap<String, String>,
}
impl TenantRegistry {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn register(&mut self, table: impl Into<String>, column: impl Into<String>) {
self.tables.insert(table.into(), column.into());
}
pub(crate) fn get(&self, table: &str) -> Option<&str> {
self.tables.get(table).map(|s| s.as_str())
}
pub(crate) fn len(&self) -> usize {
self.tables.len()
}
}
static TENANT_TABLES: LazyLock<RwLock<TenantRegistry>> =
LazyLock::new(|| RwLock::new(TenantRegistry::new()));
pub(crate) fn register_into(
lock: &RwLock<TenantRegistry>,
tables: &[(&str, &str)],
) -> Result<usize, String> {
let mut reg = lock
.write()
.map_err(|e| format!("tenant registry lock poisoned: {}", e))?;
for (table, column) in tables {
reg.register(*table, *column);
}
Ok(tables.len())
}
pub(crate) fn count_in(lock: &RwLock<TenantRegistry>) -> Result<usize, String> {
lock.read()
.map(|r| r.len())
.map_err(|e| format!("tenant registry lock poisoned: {}", e))
}
pub(crate) fn lookup_in(
lock: &RwLock<TenantRegistry>,
table: &str,
) -> Result<Option<String>, String> {
let registry = lock
.read()
.map_err(|e| format!("tenant registry lock poisoned: {}", e))?;
Ok(registry.get(table).map(|s| s.to_string()))
}
pub(crate) fn try_register_tenant_tables(tables: &[(&str, &str)]) -> Result<usize, String> {
register_into(&TENANT_TABLES, tables)
}
pub(crate) fn try_tenant_table_count() -> Result<usize, String> {
count_in(&TENANT_TABLES)
}
pub(crate) fn register_from_migrate_schema(
schema: &crate::migrate::Schema,
) -> Result<usize, String> {
let mut reg = TENANT_TABLES
.write()
.map_err(|e| format!("tenant registry lock poisoned: {}", e))?;
let mut count = 0;
for (name, table) in &schema.tables {
if table.columns.iter().any(|c| c.name == "tenant_id") {
reg.register(name.as_str(), "tenant_id");
count += 1;
}
}
Ok(count)
}
pub fn try_lookup_tenant_column(table: &str) -> Result<Option<String>, String> {
lookup_in(&TENANT_TABLES, table)
}
pub fn scoping_applies(relation: &str) -> Result<bool, String> {
try_lookup_tenant_column(relation).map(|col| col.is_some())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_register_and_lookup() {
let mut reg = TenantRegistry::new();
reg.register("orders", "tenant_id");
reg.register("bookings", "tenant_id");
assert_eq!(reg.get("orders"), Some("tenant_id"));
assert_eq!(reg.get("bookings"), Some("tenant_id"));
assert_eq!(reg.get("migrations"), None);
assert_eq!(reg.len(), 2);
}
#[test]
fn lock_level_helpers_round_trip() {
let lock = RwLock::new(TenantRegistry::new());
assert_eq!(count_in(&lock), Ok(0));
assert_eq!(
register_into(&lock, &[("_t_a", "tenant_id"), ("_t_b", "tenant_id")]),
Ok(2)
);
assert_eq!(count_in(&lock), Ok(2));
assert_eq!(lookup_in(&lock, "_t_a"), Ok(Some("tenant_id".to_string())));
assert_eq!(lookup_in(&lock, "_t_missing"), Ok(None));
}
#[test]
fn global_lookup_is_fallible_and_distinguishes_unregistered() {
crate::rls::init_scope_registries_from_tables(
&[("_tenant_global_probe", "tenant_id")],
&[],
)
.expect("boundary registration");
assert_eq!(
try_lookup_tenant_column("_tenant_global_probe"),
Ok(Some("tenant_id".to_string()))
);
assert_eq!(try_lookup_tenant_column("_tenant_global_missing"), Ok(None));
assert_eq!(scoping_applies("_tenant_global_probe"), Ok(true));
assert_eq!(scoping_applies("_tenant_global_missing"), Ok(false));
}
#[test]
fn migrate_schema_registration_counts_tenant_tables() {
let schema = crate::migrate::parse_qail(
"table _ms_orders {\n id UUID primary_key\n tenant_id UUID\n}\n\
table _ms_ref {\n id UUID primary_key\n}\n",
)
.unwrap();
assert_eq!(register_from_migrate_schema(&schema), Ok(1));
}
}