use-pg-table 0.1.0

PostgreSQL table object 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};
use use_pg_schema::PgSchemaName;

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

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

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

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

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

impl FromStr for PgTableName {
    type Err = PgTableError;

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

impl TryFrom<&str> for PgTableName {
    type Error = PgTableError;

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

/// PostgreSQL table-like object kinds.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgTableKind {
    /// Ordinary heap table.
    #[default]
    Ordinary,
    /// Declarative partitioned table.
    Partitioned,
    /// Foreign table backed by a foreign data wrapper.
    Foreign,
    /// Temporary table object.
    Temporary,
    /// PostgreSQL view.
    View,
    /// PostgreSQL materialized view.
    MaterializedView,
    /// Toast table metadata label.
    Toast,
}

impl PgTableKind {
    /// Returns a stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ordinary => "ordinary table",
            Self::Partitioned => "partitioned table",
            Self::Foreign => "foreign table",
            Self::Temporary => "temporary table",
            Self::View => "view",
            Self::MaterializedView => "materialized view",
            Self::Toast => "toast table",
        }
    }
}

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

impl FromStr for PgTableKind {
    type Err = PgTableError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "ordinary" | "ordinary table" | "table" | "base table" => Ok(Self::Ordinary),
            "partitioned" | "partitioned table" => Ok(Self::Partitioned),
            "foreign" | "foreign table" => Ok(Self::Foreign),
            "temporary" | "temporary table" | "temp" | "temp table" => Ok(Self::Temporary),
            "view" => Ok(Self::View),
            "materialized view" | "matview" => Ok(Self::MaterializedView),
            "toast" | "toast table" => Ok(Self::Toast),
            _ => Err(PgTableError::UnknownKind),
        }
    }
}

/// PostgreSQL table persistence labels.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgTablePersistence {
    /// Normal persistent table storage.
    #[default]
    Permanent,
    /// Unlogged table storage.
    Unlogged,
    /// Session-local temporary storage.
    Temporary,
}

impl PgTablePersistence {
    /// Returns a stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Permanent => "permanent",
            Self::Unlogged => "unlogged",
            Self::Temporary => "temporary",
        }
    }
}

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

impl FromStr for PgTablePersistence {
    type Err = PgTableError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "permanent" | "persistent" => Ok(Self::Permanent),
            "unlogged" => Ok(Self::Unlogged),
            "temporary" | "temp" => Ok(Self::Temporary),
            _ => Err(PgTableError::UnknownPersistence),
        }
    }
}

/// Schema-qualified PostgreSQL table reference metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgTableRef {
    schema: Option<PgSchemaName>,
    name: PgTableName,
}

impl PgTableRef {
    /// Creates an unqualified table reference.
    #[must_use]
    pub const fn new(name: PgTableName) -> Self {
        Self { schema: None, name }
    }

    /// Creates a schema-qualified table reference.
    #[must_use]
    pub const fn qualified(schema: PgSchemaName, name: PgTableName) -> Self {
        Self {
            schema: Some(schema),
            name,
        }
    }

    /// Adds schema qualification.
    #[must_use]
    pub fn with_schema(mut self, schema: PgSchemaName) -> Self {
        self.schema = Some(schema);
        self
    }

    /// Returns the optional schema name.
    #[must_use]
    pub const fn schema(&self) -> Option<&PgSchemaName> {
        self.schema.as_ref()
    }

    /// Returns the table name.
    #[must_use]
    pub const fn name(&self) -> &PgTableName {
        &self.name
    }
}

impl fmt::Display for PgTableRef {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(schema) = &self.schema {
            write!(formatter, "{schema}.")?;
        }
        write!(formatter, "{}", self.name)
    }
}

/// PostgreSQL table metadata without database introspection.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgTable {
    reference: PgTableRef,
    kind: PgTableKind,
    persistence: PgTablePersistence,
}

impl PgTable {
    /// Creates table metadata from a table reference.
    #[must_use]
    pub const fn new(reference: PgTableRef) -> Self {
        Self {
            reference,
            kind: PgTableKind::Ordinary,
            persistence: PgTablePersistence::Permanent,
        }
    }

    /// Sets the table kind.
    #[must_use]
    pub const fn with_kind(mut self, kind: PgTableKind) -> Self {
        self.kind = kind;
        self
    }

    /// Sets the persistence label.
    #[must_use]
    pub const fn with_persistence(mut self, persistence: PgTablePersistence) -> Self {
        self.persistence = persistence;
        self
    }

    /// Returns the table reference.
    #[must_use]
    pub const fn reference(&self) -> &PgTableRef {
        &self.reference
    }

    /// Returns the table kind.
    #[must_use]
    pub const fn kind(&self) -> PgTableKind {
        self.kind
    }

    /// Returns the persistence label.
    #[must_use]
    pub const fn persistence(&self) -> PgTablePersistence {
        self.persistence
    }
}

/// Error returned when PostgreSQL table metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PgTableError {
    Empty,
    UnknownKind,
    UnknownPersistence,
    Identifier(PgIdentifierError),
}

impl fmt::Display for PgTableError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("PostgreSQL table label cannot be empty"),
            Self::UnknownKind => formatter.write_str("unknown PostgreSQL table kind"),
            Self::UnknownPersistence => {
                formatter.write_str("unknown PostgreSQL table persistence label")
            }
            Self::Identifier(error) => {
                write!(formatter, "invalid PostgreSQL table identifier: {error}")
            }
        }
    }
}

impl Error for PgTableError {}

fn normalized_label(input: &str) -> Result<String, PgTableError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(PgTableError::Empty);
    }
    Ok(trimmed
        .replace('_', " ")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .to_ascii_lowercase())
}

#[cfg(test)]
mod tests {
    use super::{PgTable, PgTableError, PgTableKind, PgTableName, PgTablePersistence, PgTableRef};
    use use_pg_schema::PgSchemaName;

    #[test]
    fn renders_schema_qualified_table_refs() -> Result<(), Box<dyn std::error::Error>> {
        let table = PgTableRef::qualified(PgSchemaName::public(), PgTableName::new("users")?);
        assert_eq!(table.to_string(), "public.users");
        Ok(())
    }

    #[test]
    fn parses_table_kind_and_persistence() -> Result<(), PgTableError> {
        assert_eq!(
            "partitioned table".parse::<PgTableKind>()?,
            PgTableKind::Partitioned
        );
        assert_eq!("foreign".parse::<PgTableKind>()?, PgTableKind::Foreign);
        assert_eq!(
            "unlogged".parse::<PgTablePersistence>()?,
            PgTablePersistence::Unlogged
        );
        assert_eq!(PgTableKind::Temporary.to_string(), "temporary table");
        Ok(())
    }

    #[test]
    fn creates_table_metadata() -> Result<(), Box<dyn std::error::Error>> {
        let reference = PgTableRef::new(PgTableName::new("events")?);
        let table = PgTable::new(reference)
            .with_kind(PgTableKind::Ordinary)
            .with_persistence(PgTablePersistence::Unlogged);
        assert_eq!(table.reference().to_string(), "events");
        assert_eq!(table.persistence(), PgTablePersistence::Unlogged);
        Ok(())
    }
}