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> {
use crate::core::SqlValue;
if patch.is_empty() {
return Err(TenancyError::Validation(
"nothing to change — pass at least one field".into(),
));
}
let mut values: Vec<(&'static str, SqlValue)> = Vec::new();
if let Some(v) = patch.display_name.as_deref() {
values.push(("display_name", SqlValue::String(v.trim().to_owned())));
}
if let Some(v) = patch.host_pattern.as_deref() {
values.push(("host_pattern", SqlValue::String(v.to_owned())));
}
if let Some(v) = patch.path_prefix.as_deref() {
values.push(("path_prefix", SqlValue::String(v.to_owned())));
}
if let Some(v) = patch.port.as_deref() {
let p = if v.trim().is_empty() {
SqlValue::Null
} else {
SqlValue::I32(
v.trim()
.parse::<i32>()
.map_err(|_| TenancyError::Validation(format!("port `{v}` is not a number")))?,
)
};
values.push(("port", p));
}
if let Some(v) = patch.active {
values.push(("active", SqlValue::Bool(v)));
}
if let Some(v) = patch.database_url.as_deref() {
values.push(("database_url", SqlValue::String(v.to_owned())));
}
let mut applied = apply_values(registry, slug, values).await?;
applied.touched = patch.touched();
Ok(applied)
}
pub(crate) async fn apply_values(
registry: &Pool,
slug: &str,
values: Vec<(&'static str, crate::core::SqlValue)>,
) -> Result<Applied, TenancyError> {
use crate::core::SqlValue;
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 existing_id = existing.id.get().copied().unwrap_or_default();
let blank = |v: &SqlValue| match v {
SqlValue::Null => true,
SqlValue::String(s) => s.trim().is_empty(),
_ => false,
};
let mut set: Vec<crate::core::Assignment> = Vec::new();
let mut touched: Vec<&'static str> = Vec::new();
let mut database_url_rotated = false;
for (column, value) in values {
let value = match (column, value) {
("database_url", v) if blank(&v) => continue,
("database_url", SqlValue::String(new)) => {
database_url_rotated = existing.database_url.as_deref() != Some(new.as_str());
SqlValue::String(new)
}
("host_pattern" | "path_prefix", v) if blank(&v) => SqlValue::Null,
("host_pattern", SqlValue::String(v)) => {
let host = crate::tenancy::provision::validate_host_pattern(&v)
.map_err(TenancyError::Validation)?;
if crate::tenancy::org_host::host_claimed(registry, &host, Some(existing_id))
.await?
{
return Err(TenancyError::Validation(format!(
"host `{host}` is already used by another tenant"
)));
}
SqlValue::String(host)
}
("path_prefix", SqlValue::String(v)) => {
let v = v.trim().to_owned();
crate::tenancy::provision::validate_path_prefix(&v)
.map_err(TenancyError::Validation)?;
if crate::tenancy::org_host::prefix_claimed(registry, &v, Some(existing_id)).await?
{
return Err(TenancyError::Validation(format!(
"path prefix `{v}` is already used by another tenant"
)));
}
SqlValue::String(v)
}
("port", SqlValue::I32(n)) => {
crate::tenancy::provision::validate_port(n).map_err(TenancyError::Validation)?;
if crate::tenancy::org_host::port_claimed(registry, n, Some(existing_id)).await? {
return Err(TenancyError::Validation(format!(
"port {n} is already used by another tenant"
)));
}
SqlValue::I32(n)
}
(_, v) => v,
};
touched.push(column);
set.push(assign(column, value));
}
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,
database_url_rotated,
})
}
fn assign(column: &'static str, value: crate::core::SqlValue) -> crate::core::Assignment {
crate::core::Assignment {
column,
value: value.into(),
}
}
#[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"]);
}
}