use-pg-schema 0.1.0

PostgreSQL schema and search-path primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

use use_pg_identifier::{PgIdentifier, PgIdentifierError};

/// The common user-facing PostgreSQL schema.
pub const PUBLIC_SCHEMA: &str = "public";
/// The PostgreSQL system catalog schema.
pub const PG_CATALOG_SCHEMA: &str = "pg_catalog";
/// The SQL information schema.
pub const INFORMATION_SCHEMA: &str = "information_schema";

/// PostgreSQL schema name primitive.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgSchemaName(PgIdentifier);

impl PgSchemaName {
    /// Creates a schema name.
    ///
    /// # Errors
    ///
    /// Returns [`PgSchemaError`] when identifier validation fails.
    pub fn new(input: impl AsRef<str>) -> Result<Self, PgSchemaError> {
        PgIdentifier::new(input)
            .map(Self)
            .map_err(PgSchemaError::Identifier)
    }

    /// Returns the `public` schema name.
    ///
    /// # Panics
    ///
    /// Panics only if the built-in `public` constant is changed to an invalid identifier.
    #[must_use]
    pub fn public() -> Self {
        Self::new(PUBLIC_SCHEMA).expect("public is a valid PostgreSQL schema name")
    }

    /// Returns the `pg_catalog` schema name.
    ///
    /// # Panics
    ///
    /// Panics only if the built-in `pg_catalog` constant is changed to an invalid identifier.
    #[must_use]
    pub fn pg_catalog() -> Self {
        Self::new(PG_CATALOG_SCHEMA).expect("pg_catalog is a valid PostgreSQL schema name")
    }

    /// Returns the `information_schema` schema name.
    ///
    /// # Panics
    ///
    /// Panics only if the built-in `information_schema` constant is changed to an invalid identifier.
    #[must_use]
    pub fn information_schema() -> Self {
        Self::new(INFORMATION_SCHEMA).expect("information_schema is a valid PostgreSQL schema name")
    }

    /// Returns the schema name text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }

    /// Returns the broad schema classification.
    #[must_use]
    pub fn class(&self) -> PgSchemaClass {
        classify_schema(self.as_str())
    }

    /// Returns `true` for PostgreSQL system-owned schemas.
    #[must_use]
    pub fn is_system(&self) -> bool {
        self.class().is_system()
    }
}

impl AsRef<str> for PgSchemaName {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for PgSchemaName {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

impl FromStr for PgSchemaName {
    type Err = PgSchemaError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        Self::new(input)
    }
}

impl TryFrom<&str> for PgSchemaName {
    type Error = PgSchemaError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Broad PostgreSQL schema classification.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgSchemaClass {
    /// The `public` schema.
    Public,
    /// The PostgreSQL system catalog schema.
    SystemCatalog,
    /// The SQL information schema.
    InformationSchema,
    /// Session temporary schemas such as `pg_temp_3`.
    Temporary,
    /// PostgreSQL toast schemas.
    Toast,
    /// User-defined schemas.
    #[default]
    User,
}

impl PgSchemaClass {
    /// Returns a stable lowercase label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Public => "public",
            Self::SystemCatalog => "system-catalog",
            Self::InformationSchema => "information-schema",
            Self::Temporary => "temporary",
            Self::Toast => "toast",
            Self::User => "user",
        }
    }

    /// Returns `true` when the class is PostgreSQL-managed.
    #[must_use]
    pub const fn is_system(self) -> bool {
        matches!(
            self,
            Self::SystemCatalog | Self::InformationSchema | Self::Temporary | Self::Toast
        )
    }
}

impl fmt::Display for PgSchemaClass {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// Classifies a schema label without querying a database.
#[must_use]
pub fn classify_schema(input: &str) -> PgSchemaClass {
    let normalized = input.trim().to_ascii_lowercase();
    if normalized == PUBLIC_SCHEMA {
        PgSchemaClass::Public
    } else if normalized == PG_CATALOG_SCHEMA || normalized.starts_with("pg_catalog_") {
        PgSchemaClass::SystemCatalog
    } else if normalized == INFORMATION_SCHEMA {
        PgSchemaClass::InformationSchema
    } else if normalized == "pg_temp" || normalized.starts_with("pg_temp_") {
        PgSchemaClass::Temporary
    } else if normalized == "pg_toast" || normalized.starts_with("pg_toast") {
        PgSchemaClass::Toast
    } else {
        PgSchemaClass::User
    }
}

/// PostgreSQL search-path metadata.
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgSearchPath {
    schemas: Vec<PgSchemaName>,
}

impl PgSearchPath {
    /// Creates a search path from schema names.
    #[must_use]
    pub const fn new(schemas: Vec<PgSchemaName>) -> Self {
        Self { schemas }
    }

    /// Creates a search path containing only `public`.
    #[must_use]
    pub fn public() -> Self {
        Self::new(vec![PgSchemaName::public()])
    }

    /// Returns the schema list.
    #[must_use]
    pub fn schemas(&self) -> &[PgSchemaName] {
        &self.schemas
    }

    /// Returns the first schema in the search path.
    #[must_use]
    pub fn first(&self) -> Option<&PgSchemaName> {
        self.schemas.first()
    }

    /// Appends a schema to the search path.
    pub fn push(&mut self, schema: PgSchemaName) {
        self.schemas.push(schema);
    }

    /// Returns `true` when the search path contains `schema`.
    #[must_use]
    pub fn contains(&self, schema: &PgSchemaName) -> bool {
        self.schemas.iter().any(|candidate| candidate == schema)
    }
}

impl fmt::Display for PgSearchPath {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut schemas = self.schemas.iter();
        if let Some(first) = schemas.next() {
            write!(formatter, "{first}")?;
        }
        for schema in schemas {
            write!(formatter, ", {schema}")?;
        }
        Ok(())
    }
}

/// Error returned when PostgreSQL schema metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PgSchemaError {
    Identifier(PgIdentifierError),
}

impl fmt::Display for PgSchemaError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Identifier(error) => {
                write!(formatter, "invalid PostgreSQL schema identifier: {error}")
            }
        }
    }
}

impl Error for PgSchemaError {}

#[cfg(test)]
mod tests {
    use super::{
        INFORMATION_SCHEMA, PG_CATALOG_SCHEMA, PUBLIC_SCHEMA, PgSchemaClass, PgSchemaError,
        PgSchemaName, PgSearchPath, classify_schema,
    };

    #[test]
    fn creates_common_schema_names() {
        assert_eq!(PgSchemaName::public().as_str(), PUBLIC_SCHEMA);
        assert_eq!(PgSchemaName::pg_catalog().as_str(), PG_CATALOG_SCHEMA);
        assert_eq!(
            PgSchemaName::information_schema().as_str(),
            INFORMATION_SCHEMA
        );
    }

    #[test]
    fn classifies_schema_names() {
        assert_eq!(classify_schema("public"), PgSchemaClass::Public);
        assert_eq!(classify_schema("pg_catalog"), PgSchemaClass::SystemCatalog);
        assert_eq!(
            classify_schema("information_schema"),
            PgSchemaClass::InformationSchema
        );
        assert_eq!(classify_schema("pg_temp_3"), PgSchemaClass::Temporary);
        assert_eq!(classify_schema("app"), PgSchemaClass::User);
        assert!(PgSchemaName::pg_catalog().is_system());
    }

    #[test]
    fn tracks_search_path_order() -> Result<(), PgSchemaError> {
        let mut path = PgSearchPath::public();
        let app = PgSchemaName::new("app")?;
        path.push(app.clone());

        assert_eq!(path.first(), Some(&PgSchemaName::public()));
        assert!(path.contains(&app));
        assert_eq!(path.to_string(), "public, app");
        Ok(())
    }
}