use crate::core::{Column as _, Model as _};
use crate::sql::{FetcherPool as _, Pool};
use crate::tenancy::error::TenancyError;
use crate::tenancy::Org;
#[derive(Debug, Default, Clone)]
pub struct OrgPatch {
pub display_name: Option<String>,
pub host_pattern: Option<String>,
pub path_prefix: Option<String>,
pub port: Option<String>,
pub active: Option<bool>,
pub database_url: Option<String>,
}
impl OrgPatch {
#[must_use]
pub fn is_empty(&self) -> bool {
self.display_name.is_none()
&& self.host_pattern.is_none()
&& self.path_prefix.is_none()
&& self.port.is_none()
&& self.active.is_none()
&& self.database_url.is_none()
}
#[must_use]
pub fn touched(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if self.display_name.is_some() {
out.push("display_name");
}
if self.host_pattern.is_some() {
out.push("host_pattern");
}
if self.path_prefix.is_some() {
out.push("path_prefix");
}
if self.port.is_some() {
out.push("port");
}
if self.active.is_some() {
out.push("active");
}
if self.database_url.is_some() {
out.push("database_url");
}
out
}
}
#[derive(Debug)]
pub struct Applied {
pub touched: Vec<&'static str>,
pub database_url_rotated: bool,
}
pub async fn apply(registry: &Pool, slug: &str, patch: &OrgPatch) -> Result<Applied, TenancyError> {
if patch.is_empty() {
return Err(TenancyError::Validation(
"nothing to change — pass at least one field".into(),
));
}
let existing = Org::objects()
.where_(Org::slug.eq(slug.to_owned()))
.fetch(registry)
.await?
.into_iter()
.next()
.ok_or_else(|| TenancyError::Validation(format!("tenant `{slug}` not found")))?;
let mut set: Vec<crate::core::Assignment> = Vec::new();
if let Some(v) = patch.display_name.as_deref() {
set.push(assign(
"display_name",
crate::core::SqlValue::String(v.trim().to_owned()),
));
}
if let Some(v) = patch.host_pattern.as_deref() {
let normalized = if v.trim().is_empty() {
None
} else {
Some(
crate::tenancy::provision::validate_host_pattern(v)
.map_err(TenancyError::Validation)?,
)
};
set.push(assign("host_pattern", string_or_null(normalized)));
}
if let Some(v) = patch.path_prefix.as_deref() {
let cleaned = if v.trim().is_empty() {
None
} else {
crate::tenancy::provision::validate_path_prefix(v).map_err(TenancyError::Validation)?;
Some(v.trim().to_owned())
};
set.push(assign("path_prefix", string_or_null(cleaned)));
}
if let Some(v) = patch.port.as_deref() {
let p = if v.trim().is_empty() {
None
} else {
let n = v
.trim()
.parse::<i32>()
.map_err(|_| TenancyError::Validation(format!("port `{v}` is not a number")))?;
crate::tenancy::provision::validate_port(n).map_err(TenancyError::Validation)?;
Some(n)
};
set.push(assign(
"port",
p.map_or(crate::core::SqlValue::Null, crate::core::SqlValue::I32),
));
}
if let Some(v) = patch.active {
set.push(assign("active", crate::core::SqlValue::Bool(v)));
}
let database_url_rotated = patch
.database_url
.as_deref()
.filter(|u| !u.trim().is_empty())
.is_some_and(|new| existing.database_url.as_deref() != Some(new));
if let Some(v) = patch
.database_url
.as_deref()
.filter(|u| !u.trim().is_empty())
{
set.push(assign(
"database_url",
crate::core::SqlValue::String(v.to_owned()),
));
}
if set.is_empty() {
return Err(TenancyError::Validation(
"nothing to change — pass at least one field".into(),
));
}
let update = crate::core::UpdateQuery {
model: Org::SCHEMA,
set,
where_clause: crate::core::WhereExpr::and_predicates(vec![crate::core::Filter {
column: "slug",
op: crate::core::Op::Eq,
value: crate::core::SqlValue::String(slug.to_owned()),
}]),
};
crate::sql::update_pool(registry, &update).await?;
crate::tenancy::invalidate_org_cache();
Ok(Applied {
touched: patch.touched(),
database_url_rotated,
})
}
fn assign(column: &'static str, value: crate::core::SqlValue) -> crate::core::Assignment {
crate::core::Assignment {
column,
value: value.into(),
}
}
fn string_or_null(v: Option<String>) -> crate::core::SqlValue {
v.map_or(crate::core::SqlValue::Null, crate::core::SqlValue::String)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_patch_is_refused_rather_than_run() {
assert!(OrgPatch::default().is_empty());
let p = OrgPatch {
display_name: Some("Acme".into()),
..OrgPatch::default()
};
assert!(!p.is_empty());
}
#[test]
fn touched_lists_every_supplied_field() {
let p = OrgPatch {
display_name: Some(String::new()),
host_pattern: Some("shop.test".into()),
active: Some(false),
..OrgPatch::default()
};
assert_eq!(p.touched(), vec!["display_name", "host_pattern", "active"]);
}
#[test]
fn a_cleared_routing_column_becomes_null() {
assert!(matches!(string_or_null(None), crate::core::SqlValue::Null));
assert!(matches!(
string_or_null(Some("shop.test".into())),
crate::core::SqlValue::String(_)
));
}
}