use std::borrow::Cow;
#[derive(Debug, Clone)]
pub struct Tenancy {
pub(crate) guc_name: Cow<'static, str>,
pub(crate) schemas: Vec<Cow<'static, str>>,
pub(crate) tenant_column: Cow<'static, str>,
}
impl Default for Tenancy {
fn default() -> Self {
Self {
guc_name: Cow::Borrowed("app.tenant_id"),
schemas: vec![Cow::Borrowed("public")],
tenant_column: Cow::Borrowed("tenant_id"),
}
}
}
impl Tenancy {
pub fn new() -> Self {
Self::default()
}
pub fn guc(mut self, name: impl Into<Cow<'static, str>>) -> Self {
let name = name.into();
validate_guc(&name);
self.guc_name = name;
self
}
pub fn schema(mut self, name: impl Into<Cow<'static, str>>) -> Self {
let name = name.into();
validate_identifier(&name);
self.schemas = vec![name];
self
}
pub fn schemas<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Cow<'static, str>>,
{
let names: Vec<Cow<'static, str>> = names.into_iter().map(Into::into).collect();
assert!(
!names.is_empty(),
"tenaxum: Tenancy::schemas needs at least one schema"
);
for n in &names {
validate_identifier(n);
}
self.schemas = names;
self
}
pub fn tenant_column(mut self, name: impl Into<Cow<'static, str>>) -> Self {
let name = name.into();
validate_identifier(&name);
self.tenant_column = name;
self
}
pub fn guc_name(&self) -> &str {
&self.guc_name
}
pub fn schemas_slice(&self) -> Vec<&str> {
self.schemas.iter().map(|s| s.as_ref()).collect()
}
pub fn tenant_column_name(&self) -> &str {
&self.tenant_column
}
}
pub(crate) fn validate_identifier(s: &str) {
assert!(!s.is_empty(), "tenaxum: identifier is empty");
assert!(
s.len() <= 63,
"tenaxum: identifier `{s}` exceeds 63 chars (Postgres NAMEDATALEN)"
);
let mut chars = s.chars();
let first = chars.next().unwrap();
assert!(
first.is_ascii_alphabetic() || first == '_',
"tenaxum: identifier `{s}` must start with an ASCII letter or `_`"
);
for c in chars {
assert!(
c.is_ascii_alphanumeric() || c == '_',
"tenaxum: identifier `{s}` contains invalid char `{c}` \
(only ASCII alphanumerics and `_` allowed)"
);
}
}
pub(crate) fn validate_guc(s: &str) {
assert!(!s.is_empty(), "tenaxum: GUC name is empty");
let parts: Vec<&str> = s.split('.').collect();
assert!(
parts.len() == 2,
"tenaxum: GUC name `{s}` must be exactly two identifiers \
(`<class>.<key>`)"
);
for part in parts {
validate_identifier(part);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values() {
let t = Tenancy::default();
assert_eq!(t.guc_name(), "app.tenant_id");
assert_eq!(t.schemas_slice(), vec!["public"]);
assert_eq!(t.tenant_column_name(), "tenant_id");
}
#[test]
fn builder_overrides() {
let t = Tenancy::new()
.guc("app.org_id")
.schema("app")
.tenant_column("org_id");
assert_eq!(t.guc_name(), "app.org_id");
assert_eq!(t.schemas_slice(), vec!["app"]);
assert_eq!(t.tenant_column_name(), "org_id");
}
#[test]
fn schemas_multi() {
let t = Tenancy::new().schemas(["app", "data"]);
assert_eq!(t.schemas_slice(), vec!["app", "data"]);
}
#[test]
#[should_panic(expected = "must be exactly two identifiers")]
fn rejects_dash_in_guc() {
let _ = Tenancy::new().guc("bad-guc");
}
#[test]
#[should_panic(expected = "must be exactly two identifiers")]
fn rejects_three_part_guc() {
let _ = Tenancy::new().guc("a.b.c");
}
#[test]
#[should_panic(expected = "must be exactly two identifiers")]
fn rejects_single_part_guc() {
let _ = Tenancy::new().guc("tenant_id");
}
#[test]
#[should_panic(expected = "identifier `1abc`")]
fn rejects_leading_digit() {
let _ = Tenancy::new().schema("1abc");
}
#[test]
#[should_panic(expected = "needs at least one schema")]
fn rejects_empty_schemas() {
let _ = Tenancy::new().schemas::<[&str; 0], _>([]);
}
#[test]
fn accepts_underscores_and_digits() {
let t = Tenancy::new()
.guc("app2.tenant_id_v2")
.schema("app_v2")
.tenant_column("tenant_id_2");
assert_eq!(t.guc_name(), "app2.tenant_id_v2");
}
}