Skip to main content

eventuary_postgres/
relation.rs

1use eventuary_core::{Error, Result};
2
3#[derive(Debug, Clone, Eq, PartialEq, Hash)]
4pub struct PgRelationName {
5    schema: Option<String>,
6    table: String,
7}
8
9impl PgRelationName {
10    pub fn new(name: impl Into<String>) -> Result<Self> {
11        let raw = name.into();
12        let parts: Vec<&str> = raw.split('.').collect();
13        match parts.len() {
14            1 => {
15                validate_identifier(parts[0])?;
16                Ok(Self {
17                    schema: None,
18                    table: parts[0].to_owned(),
19                })
20            }
21            2 => {
22                validate_identifier(parts[0])?;
23                validate_identifier(parts[1])?;
24                Ok(Self {
25                    schema: Some(parts[0].to_owned()),
26                    table: parts[1].to_owned(),
27                })
28            }
29            _ => Err(Error::Config(format!(
30                "invalid postgres relation name: {raw}"
31            ))),
32        }
33    }
34
35    pub fn schema(&self) -> Option<&str> {
36        self.schema.as_deref()
37    }
38
39    pub fn table(&self) -> &str {
40        &self.table
41    }
42
43    pub fn render(&self) -> String {
44        match &self.schema {
45            Some(schema) => format!("\"{}\".\"{}\"", schema, self.table),
46            None => format!("\"{}\"", self.table),
47        }
48    }
49}
50
51fn validate_identifier(identifier: &str) -> Result<()> {
52    if identifier.is_empty() || identifier.len() > 63 {
53        return Err(Error::Config(format!(
54            "invalid postgres identifier length: {identifier:?}"
55        )));
56    }
57    let mut chars = identifier.chars();
58    let Some(first) = chars.next() else {
59        return Err(Error::Config("empty postgres identifier".to_owned()));
60    };
61    if !(first == '_' || first.is_ascii_alphabetic()) {
62        return Err(Error::Config(format!(
63            "postgres identifier must start with letter or underscore: {identifier:?}"
64        )));
65    }
66    if !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
67        return Err(Error::Config(format!(
68            "postgres identifier may only contain letters, digits, underscores: {identifier:?}"
69        )));
70    }
71    Ok(())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn renders_simple_relation() {
80        let relation = PgRelationName::new("events").unwrap();
81        assert_eq!(relation.render(), "\"events\"");
82    }
83
84    #[test]
85    fn renders_schema_qualified_relation() {
86        let relation = PgRelationName::new("eventuary.events").unwrap();
87        assert_eq!(relation.render(), "\"eventuary\".\"events\"");
88    }
89
90    #[test]
91    fn rejects_invalid_relation() {
92        assert!(PgRelationName::new("events;drop").is_err());
93        assert!(PgRelationName::new("public.events.extra").is_err());
94        assert!(PgRelationName::new("9events").is_err());
95        assert!(PgRelationName::new("").is_err());
96    }
97}