use crate::Model;
#[derive(Model, Clone, serde::Serialize)]
#[rustango(table = "rustango_orgs", display = "slug", scope = "registry")]
#[allow(dead_code)] pub struct Org {
#[rustango(primary_key)]
pub id: rustango::Auto<i64>,
#[rustango(max_length = 64, unique)]
pub slug: String,
#[rustango(max_length = 255)]
pub display_name: String,
#[rustango(max_length = 16)]
pub storage_mode: String,
#[rustango(max_length = 16, default = "'postgres'")]
pub backend_kind: String,
pub database_url: Option<String>,
#[rustango(max_length = 64)]
pub schema_name: Option<String>,
#[rustango(max_length = 255)]
pub host_pattern: Option<String>,
pub port: Option<i32>,
#[rustango(max_length = 255, index)]
pub path_prefix: Option<String>,
pub active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[rustango(max_length = 80)]
pub brand_name: Option<String>,
#[rustango(max_length = 200)]
pub brand_tagline: Option<String>,
#[rustango(max_length = 120)]
pub logo_path: Option<String>,
#[rustango(max_length = 120)]
pub favicon_path: Option<String>,
#[rustango(max_length = 7)]
pub primary_color: Option<String>,
#[rustango(max_length = 8)]
pub theme_mode: Option<String>,
}
impl std::fmt::Debug for Org {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Org")
.field("id", &self.id)
.field("slug", &self.slug)
.field("display_name", &self.display_name)
.field("storage_mode", &self.storage_mode)
.field("backend_kind", &self.backend_kind)
.field("database_url", &redacted_url(self.database_url.as_deref()))
.field("schema_name", &self.schema_name)
.field("host_pattern", &self.host_pattern)
.field("port", &self.port)
.field("path_prefix", &self.path_prefix)
.field("active", &self.active)
.field("created_at", &self.created_at)
.field("brand_name", &self.brand_name)
.field("brand_tagline", &self.brand_tagline)
.field("logo_path", &self.logo_path)
.field("favicon_path", &self.favicon_path)
.field("primary_color", &self.primary_color)
.field("theme_mode", &self.theme_mode)
.finish()
}
}
pub(crate) fn redacted_url(url: Option<&str>) -> Option<String> {
url.map(crate::sql::connect_diagnosis::redact)
}
impl Org {
pub(crate) fn effective_schema(&self) -> &str {
effective_schema(self.schema_name.as_deref(), &self.slug)
}
pub(crate) fn own_schema(&self) -> Option<&str> {
matches!(
StorageMode::parse(&self.storage_mode),
Ok(StorageMode::Schema)
)
.then(|| self.effective_schema())
}
}
pub(crate) fn effective_schema<'a>(schema_name: Option<&'a str>, slug: &'a str) -> &'a str {
schema_name.unwrap_or(slug)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageMode {
Schema,
Database,
}
impl StorageMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Schema => "schema",
Self::Database => "database",
}
}
pub fn parse(s: &str) -> Result<Self, &str> {
match s {
"schema" => Ok(Self::Schema),
"database" => Ok(Self::Database),
other => Err(other),
}
}
}
impl core::fmt::Display for StorageMode {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BackendKind {
#[default]
Postgres,
MySql,
Sqlite,
}
impl BackendKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::MySql => "mysql",
Self::Sqlite => "sqlite",
}
}
pub fn parse(s: &str) -> Result<Self, &str> {
match s {
"postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
"mysql" | "mariadb" => Ok(Self::MySql),
"sqlite" | "sqlite3" => Ok(Self::Sqlite),
other => Err(other),
}
}
#[must_use]
pub fn of_url(url: &str) -> Option<Self> {
match url.split(':').next()?.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" => Some(Self::Postgres),
"mysql" => Some(Self::MySql),
"sqlite" => Some(Self::Sqlite),
_ => None,
}
}
#[must_use]
pub fn of_pool(pool: &crate::sql::Pool) -> Self {
match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(_) => Self::Postgres,
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(_) => Self::MySql,
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(_) => Self::Sqlite,
}
}
pub const fn validate_storage_mode(self, mode: StorageMode) -> Result<(), &'static str> {
match (self, mode) {
(Self::Postgres, _) => Ok(()),
(_, StorageMode::Database) => Ok(()),
(Self::MySql, StorageMode::Schema) => {
Err("MySQL does not support schema mode — choose the database storage mode")
}
(Self::Sqlite, StorageMode::Schema) => Err(
"SQLite does not support schema mode — choose the database storage mode, \
which gives each tenant its own file",
),
}
}
}
impl core::fmt::Display for BackendKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OrgGeneration {
pub count: i64,
pub active: i64,
pub max_id: i64,
pub active_id_sum: i64,
}
pub async fn generation(
registry: &crate::sql::Pool,
) -> Result<OrgGeneration, crate::sql::ExecError> {
use crate::core::aggregates::{count_all, max, sum};
use crate::core::{AggregateQuery, Column as _, Model as _, WhereExpr};
use crate::sql::fetch_aggregate_pool;
let q = AggregateQuery {
model: Org::SCHEMA,
joins: Vec::new(),
where_clause: WhereExpr::And(Vec::new()),
group_by: Vec::new(),
aggregates: vec![
("n".into(), count_all().into()),
(
"n_on".into(),
count_all().filter(Org::active.eq(true)).into(),
),
("max_id".into(), max("id").into()),
(
"on_id_sum".into(),
sum("id").filter(Org::active.eq(true)).into(),
),
],
aliases: Vec::new(),
having: None,
order_by: Vec::new(),
limit: None,
offset: None,
source: None,
};
let rows: Vec<(Option<i64>, Option<i64>, Option<i64>, Option<i64>)> =
fetch_aggregate_pool(registry, &q).await?;
let (n, n_on, max_id, on_id_sum) = rows.into_iter().next().unwrap_or((None, None, None, None));
Ok(OrgGeneration {
count: n.unwrap_or(0),
active: n_on.unwrap_or(0),
max_id: max_id.unwrap_or(0),
active_id_sum: on_id_sum.unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn org_debug_masks_the_database_password() {
let org = Org {
id: rustango::Auto::Set(1),
slug: "acme".into(),
display_name: "Acme".into(),
storage_mode: "database".into(),
backend_kind: "postgres".into(),
database_url: Some("postgres://u:db-pw-123@h/acme".into()),
schema_name: None,
host_pattern: None,
port: None,
path_prefix: None,
active: true,
created_at: chrono::Utc::now(),
brand_name: None,
brand_tagline: None,
logo_path: None,
favicon_path: None,
primary_color: None,
theme_mode: None,
};
let s = format!("{org:?}");
assert!(s.contains("acme") && !s.contains("db-pw-123"), "{s}");
}
#[test]
fn parse_accepts_aliases() {
assert_eq!(
BackendKind::parse("postgres").unwrap(),
BackendKind::Postgres
);
assert_eq!(
BackendKind::parse("postgresql").unwrap(),
BackendKind::Postgres
);
assert_eq!(BackendKind::parse("pg").unwrap(), BackendKind::Postgres);
assert_eq!(BackendKind::parse("mysql").unwrap(), BackendKind::MySql);
assert_eq!(BackendKind::parse("mariadb").unwrap(), BackendKind::MySql);
assert_eq!(BackendKind::parse("sqlite").unwrap(), BackendKind::Sqlite);
assert_eq!(BackendKind::parse("sqlite3").unwrap(), BackendKind::Sqlite);
}
#[test]
fn parse_rejects_unknown() {
assert!(BackendKind::parse("oracle").is_err());
assert!(BackendKind::parse("").is_err());
}
#[test]
fn validate_storage_mode_postgres_accepts_both() {
assert!(BackendKind::Postgres
.validate_storage_mode(StorageMode::Schema)
.is_ok());
assert!(BackendKind::Postgres
.validate_storage_mode(StorageMode::Database)
.is_ok());
}
#[test]
fn validate_storage_mode_mysql_rejects_schema() {
let err = BackendKind::MySql
.validate_storage_mode(StorageMode::Schema)
.unwrap_err();
assert!(err.contains("MySQL"));
assert_operator_facing(err);
}
fn assert_operator_facing(msg: &str) {
assert!(!msg.contains("storage_mode="), "wire value: {msg}");
assert!(!msg.contains("--"), "CLI flag: {msg}");
assert!(msg.contains("database storage mode"), "{msg}");
}
#[test]
fn validate_storage_mode_sqlite_rejects_schema() {
let err = BackendKind::Sqlite
.validate_storage_mode(StorageMode::Schema)
.unwrap_err();
assert!(err.contains("SQLite"));
assert_operator_facing(err);
}
#[test]
fn non_postgres_accepts_database_mode() {
assert!(BackendKind::MySql
.validate_storage_mode(StorageMode::Database)
.is_ok());
assert!(BackendKind::Sqlite
.validate_storage_mode(StorageMode::Database)
.is_ok());
}
#[test]
fn default_is_postgres() {
assert_eq!(BackendKind::default(), BackendKind::Postgres);
}
}