eventuary-postgres 0.1.0-alpha.1

PostgreSQL event backend for eventuary
Documentation
use eventuary_core::{Error, Result};

#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct PgRelationName {
    schema: Option<String>,
    table: String,
}

impl PgRelationName {
    pub fn new(name: impl Into<String>) -> Result<Self> {
        let raw = name.into();
        let parts: Vec<&str> = raw.split('.').collect();
        match parts.len() {
            1 => {
                validate_identifier(parts[0])?;
                Ok(Self {
                    schema: None,
                    table: parts[0].to_owned(),
                })
            }
            2 => {
                validate_identifier(parts[0])?;
                validate_identifier(parts[1])?;
                Ok(Self {
                    schema: Some(parts[0].to_owned()),
                    table: parts[1].to_owned(),
                })
            }
            _ => Err(Error::Config(format!(
                "invalid postgres relation name: {raw}"
            ))),
        }
    }

    pub fn schema(&self) -> Option<&str> {
        self.schema.as_deref()
    }

    pub fn table(&self) -> &str {
        &self.table
    }

    pub fn render(&self) -> String {
        match &self.schema {
            Some(schema) => format!("\"{}\".\"{}\"", schema, self.table),
            None => format!("\"{}\"", self.table),
        }
    }
}

fn validate_identifier(identifier: &str) -> Result<()> {
    if identifier.is_empty() || identifier.len() > 63 {
        return Err(Error::Config(format!(
            "invalid postgres identifier length: {identifier:?}"
        )));
    }
    let mut chars = identifier.chars();
    let Some(first) = chars.next() else {
        return Err(Error::Config("empty postgres identifier".to_owned()));
    };
    if !(first == '_' || first.is_ascii_alphabetic()) {
        return Err(Error::Config(format!(
            "postgres identifier must start with letter or underscore: {identifier:?}"
        )));
    }
    if !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
        return Err(Error::Config(format!(
            "postgres identifier may only contain letters, digits, underscores: {identifier:?}"
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_simple_relation() {
        let relation = PgRelationName::new("events").unwrap();
        assert_eq!(relation.render(), "\"events\"");
    }

    #[test]
    fn renders_schema_qualified_relation() {
        let relation = PgRelationName::new("eventuary.events").unwrap();
        assert_eq!(relation.render(), "\"eventuary\".\"events\"");
    }

    #[test]
    fn rejects_invalid_relation() {
        assert!(PgRelationName::new("events;drop").is_err());
        assert!(PgRelationName::new("public.events.extra").is_err());
        assert!(PgRelationName::new("9events").is_err());
        assert!(PgRelationName::new("").is_err());
    }
}