use fraiseql_core::db::{postgres::SearchPath, traits::DatabaseAdapter};
use fraiseql_error::{FraiseQLError, Result};
pub(crate) const MAX_PG_IDENTIFIER_LEN: usize = 63;
pub(crate) const TENANT_SCHEMA_PREFIX: &str = "tenant_";
pub fn tenant_schema_name(key: &str) -> Result<String> {
if key.is_empty() {
return Err(FraiseQLError::validation("Tenant key must not be empty for schema isolation"));
}
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(FraiseQLError::validation(format!(
"Tenant key '{key}' contains invalid characters. \
Only ASCII alphanumeric and underscore are allowed for schema isolation."
)));
}
let schema_name = format!("{TENANT_SCHEMA_PREFIX}{key}");
if schema_name.len() > MAX_PG_IDENTIFIER_LEN {
return Err(FraiseQLError::validation(format!(
"Tenant schema name '{schema_name}' exceeds PostgreSQL's \
{MAX_PG_IDENTIFIER_LEN}-character identifier limit. \
Use a shorter tenant key."
)));
}
Ok(schema_name)
}
pub fn tenant_search_path(key: &str) -> Result<SearchPath> {
let schema_name = tenant_schema_name(key)?;
SearchPath::new([schema_name.as_str(), "public"])
}
pub fn create_schema_ddl(key: &str) -> Result<String> {
let schema_name = tenant_schema_name(key)?;
Ok(format!("CREATE SCHEMA IF NOT EXISTS {schema_name}"))
}
pub async fn provision_tenant_schema(key: &str, adapter: &dyn DatabaseAdapter) -> Result<()> {
let schema_name = tenant_schema_name(key)?;
let inherited = count_relations(&schema_name, adapter).await;
let ddl = create_schema_ddl(key)?;
adapter.execute_raw_query(&ddl).await.map_err(|e| {
FraiseQLError::database(format!("Failed to provision schema for tenant '{key}': {e}"))
})?;
if inherited > 0 {
tracing::warn!(
tenant_key = key,
schema = %schema_name,
relations = inherited,
"tenant registration adopted an existing schema that already contains \
{inherited} relation(s). If this key was recycled, the new tenant will read \
the previous tenant's rows — delete with ?purge=true to drop the schema first."
);
}
Ok(())
}
async fn count_relations(schema_name: &str, adapter: &dyn DatabaseAdapter) -> u64 {
let sql = format!(
"SELECT count(*)::text AS n FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{schema_name}'"
);
adapter
.execute_raw_query(&sql)
.await
.ok()
.and_then(|rows| {
rows.first()
.and_then(|r| r.get("n"))
.and_then(serde_json::Value::as_str)
.and_then(|s| s.parse::<u64>().ok())
})
.unwrap_or(0)
}
pub async fn drop_tenant_schema(
key: &str,
executor: &fraiseql_core::runtime::Executor,
) -> Result<()> {
let schema_name = tenant_schema_name(key)?;
executor.drop_tenant_schema(&schema_name).await.map_err(|e| {
FraiseQLError::database(format!("Failed to drop schema for tenant '{key}': {e}"))
})?;
Ok(())
}
pub async fn verify_search_path(key: &str, adapter: &dyn DatabaseAdapter) -> Result<()> {
let expected = tenant_search_path(key)?;
let rows = adapter
.execute_raw_query("SELECT reset_val FROM pg_settings WHERE name = 'search_path'")
.await
.map_err(|e| {
FraiseQLError::database(format!(
"Failed to verify schema isolation for tenant '{key}': {e}. Schema-per-tenant \
tenancy requires a PostgreSQL adapter whose pool applies the tenant search \
path at connection establishment."
))
})?;
let actual = rows
.first()
.and_then(|row| row.get("reset_val"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let normalized: Vec<&str> = actual.split(',').map(str::trim).collect();
let wanted: Vec<&str> = expected.as_str().split(',').map(str::trim).collect();
if normalized == wanted {
return Ok(());
}
Err(FraiseQLError::Configuration {
message: format!(
"Schema isolation for tenant '{key}' is not in force: connections in this \
tenant's pool are established with search_path `{actual}`, expected \
`{expected}`. Queries would resolve unqualified relations outside the \
tenant's schema. Refusing to register the tenant."
),
})
}