use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::RwLock;
#[derive(Debug, Default)]
pub(crate) struct OwnerRegistry {
tables: HashMap<String, String>,
}
impl OwnerRegistry {
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 OWNER_TABLES: LazyLock<RwLock<OwnerRegistry>> =
LazyLock::new(|| RwLock::new(OwnerRegistry::new()));
pub(crate) fn register_into(
lock: &RwLock<OwnerRegistry>,
tables: &[(&str, &str)],
) -> Result<usize, String> {
let mut reg = lock
.write()
.map_err(|e| format!("owner registry lock poisoned: {}", e))?;
for (table, column) in tables {
reg.register(*table, *column);
}
Ok(tables.len())
}
pub(crate) fn count_in(lock: &RwLock<OwnerRegistry>) -> Result<usize, String> {
lock.read()
.map(|r| r.len())
.map_err(|e| format!("owner registry lock poisoned: {}", e))
}
pub(crate) fn lookup_in(
lock: &RwLock<OwnerRegistry>,
table: &str,
) -> Result<Option<String>, String> {
let registry = lock
.read()
.map_err(|e| format!("owner registry lock poisoned: {}", e))?;
Ok(registry.get(table).map(|s| s.to_string()))
}
pub(crate) fn try_register_owner_tables(tables: &[(&str, &str)]) -> Result<usize, String> {
register_into(&OWNER_TABLES, tables)
}
pub(crate) fn try_owner_table_count() -> Result<usize, String> {
count_in(&OWNER_TABLES)
}
pub(crate) fn register_from_migrate_schema(
schema: &crate::migrate::Schema,
) -> Result<usize, String> {
let mut reg = OWNER_TABLES
.write()
.map_err(|e| format!("owner registry lock poisoned: {}", e))?;
let mut count = 0;
for (name, table) in &schema.tables {
if let Some(column) = table.owner_column.as_deref() {
reg.register(name.as_str(), column);
count += 1;
}
}
Ok(count)
}
pub fn try_lookup_owner_column(table: &str) -> Result<Option<String>, String> {
lookup_in(&OWNER_TABLES, table)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_round_trips_owner_column() {
let mut reg = OwnerRegistry::new();
assert_eq!(reg.len(), 0);
reg.register("listings", "seller_id");
assert_eq!(reg.get("listings"), Some("seller_id"));
assert_eq!(reg.get("orders"), None);
assert_eq!(reg.len(), 1);
}
#[test]
fn migrate_schema_only_registers_declared_owner_columns() {
let schema = crate::migrate::parse_qail(
"table _o_listings {\n id UUID primary_key\n seller_id UUID\n owner seller_id\n}\n\
table _o_messages {\n id UUID primary_key\n user_id UUID\n}\n",
)
.expect("schema parses");
let lock = RwLock::new(OwnerRegistry::new());
let declared: Vec<(&str, &str)> = schema
.tables
.iter()
.filter_map(|(n, t)| t.owner_column.as_deref().map(|c| (n.as_str(), c)))
.collect();
assert_eq!(register_into(&lock, &declared), Ok(1));
assert_eq!(
lookup_in(&lock, "_o_listings"),
Ok(Some("seller_id".to_string()))
);
assert_eq!(
lookup_in(&lock, "_o_messages"),
Ok(None),
"a column named user_id must never be inferred as an owner scope"
);
assert_eq!(register_from_migrate_schema(&schema), Ok(1));
}
#[test]
fn global_lookup_is_fallible_and_distinguishes_unregistered() {
crate::rls::init_scope_registries_from_tables(&[], &[("_owner_global_probe", "author_id")])
.expect("boundary registration");
assert_eq!(
try_lookup_owner_column("_owner_global_probe"),
Ok(Some("author_id".to_string()))
);
assert_eq!(try_lookup_owner_column("_owner_global_missing"), Ok(None));
}
}