use-pg-column 0.1.0

PostgreSQL column metadata 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_type::{PgBuiltInType, PgTypeName};

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

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

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

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

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

impl FromStr for PgColumnName {
    type Err = PgColumnError;

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

impl TryFrom<&str> for PgColumnName {
    type Error = PgColumnError;

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

/// A PostgreSQL column default expression label.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgColumnDefault(String);

impl PgColumnDefault {
    /// Creates a default-expression label without parsing SQL.
    ///
    /// # Errors
    ///
    /// Returns [`PgColumnError`] when the label is empty or contains control characters.
    pub fn new(input: impl AsRef<str>) -> Result<Self, PgColumnError> {
        validate_label(input.as_ref(), PgColumnError::EmptyDefault)
            .map(|value| Self(value.to_owned()))
    }

    /// Returns the stored default label.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

/// PostgreSQL column nullability labels.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgNullability {
    /// The column accepts null values.
    #[default]
    Nullable,
    /// The column is marked `NOT NULL`.
    NotNull,
}

impl PgNullability {
    /// Returns `true` when the column accepts null values.
    #[must_use]
    pub const fn is_nullable(self) -> bool {
        matches!(self, Self::Nullable)
    }

    /// Returns a stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Nullable => "NULL",
            Self::NotNull => "NOT NULL",
        }
    }
}

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

impl FromStr for PgNullability {
    type Err = PgColumnError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input, PgColumnError::UnknownNullability)?.as_str() {
            "null" | "nullable" => Ok(Self::Nullable),
            "not null" | "notnull" | "required" => Ok(Self::NotNull),
            _ => Err(PgColumnError::UnknownNullability),
        }
    }
}

/// PostgreSQL generated-column labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgGeneratedKind {
    /// Generated column stored on disk.
    Stored,
    /// Virtual generated-column label for compatibility metadata.
    Virtual,
}

impl PgGeneratedKind {
    /// Returns a stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Stored => "STORED",
            Self::Virtual => "VIRTUAL",
        }
    }
}

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

/// PostgreSQL identity-column labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgIdentityKind {
    /// `GENERATED ALWAYS AS IDENTITY`.
    Always,
    /// `GENERATED BY DEFAULT AS IDENTITY`.
    ByDefault,
}

impl PgIdentityKind {
    /// Returns a stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Always => "ALWAYS",
            Self::ByDefault => "BY DEFAULT",
        }
    }
}

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

/// PostgreSQL column metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgColumn {
    name: PgColumnName,
    type_name: PgTypeName,
    nullability: PgNullability,
    default: Option<PgColumnDefault>,
    generated: Option<PgGeneratedKind>,
    identity: Option<PgIdentityKind>,
}

impl PgColumn {
    /// Creates column metadata from a name and type name.
    #[must_use]
    pub const fn new(name: PgColumnName, type_name: PgTypeName) -> Self {
        Self {
            name,
            type_name,
            nullability: PgNullability::Nullable,
            default: None,
            generated: None,
            identity: None,
        }
    }

    /// Creates column metadata using a built-in PostgreSQL type.
    #[must_use]
    pub fn with_built_in_type(name: PgColumnName, ty: PgBuiltInType) -> Self {
        Self::new(name, PgTypeName::built_in(ty))
    }

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

    /// Sets a default-expression label.
    #[must_use]
    pub fn with_default(mut self, default: PgColumnDefault) -> Self {
        self.default = Some(default);
        self
    }

    /// Sets the generated-column label.
    #[must_use]
    pub const fn with_generated(mut self, generated: PgGeneratedKind) -> Self {
        self.generated = Some(generated);
        self
    }

    /// Sets the identity-column label.
    #[must_use]
    pub const fn with_identity(mut self, identity: PgIdentityKind) -> Self {
        self.identity = Some(identity);
        self
    }

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

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

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

    /// Returns the optional default label.
    #[must_use]
    pub const fn default(&self) -> Option<&PgColumnDefault> {
        self.default.as_ref()
    }

    /// Returns the optional generated-column label.
    #[must_use]
    pub const fn generated(&self) -> Option<PgGeneratedKind> {
        self.generated
    }

    /// Returns the optional identity-column label.
    #[must_use]
    pub const fn identity(&self) -> Option<PgIdentityKind> {
        self.identity
    }
}

/// Error returned when PostgreSQL column metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PgColumnError {
    EmptyDefault,
    ControlCharacter,
    UnknownNullability,
    Identifier(PgIdentifierError),
}

impl fmt::Display for PgColumnError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyDefault => formatter.write_str("PostgreSQL column default cannot be empty"),
            Self::ControlCharacter => {
                formatter.write_str("PostgreSQL column label cannot contain control characters")
            }
            Self::UnknownNullability => {
                formatter.write_str("unknown PostgreSQL column nullability label")
            }
            Self::Identifier(error) => {
                write!(formatter, "invalid PostgreSQL column identifier: {error}")
            }
        }
    }
}

impl Error for PgColumnError {}

fn validate_label(input: &str, empty_error: PgColumnError) -> Result<&str, PgColumnError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(empty_error);
    }
    if trimmed.chars().any(char::is_control) {
        return Err(PgColumnError::ControlCharacter);
    }
    Ok(trimmed)
}

fn normalized_label(input: &str, empty_error: PgColumnError) -> Result<String, PgColumnError> {
    let trimmed = validate_label(input, empty_error)?;
    Ok(trimmed
        .replace('_', " ")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .to_ascii_lowercase())
}

#[cfg(test)]
mod tests {
    use super::{
        PgColumn, PgColumnDefault, PgColumnError, PgColumnName, PgGeneratedKind, PgIdentityKind,
        PgNullability,
    };
    use use_pg_type::PgBuiltInType;

    #[test]
    fn creates_column_metadata() -> Result<(), PgColumnError> {
        let column = PgColumn::with_built_in_type(PgColumnName::new("id")?, PgBuiltInType::BigInt)
            .with_nullability(PgNullability::NotNull)
            .with_identity(PgIdentityKind::Always);

        assert_eq!(column.name().as_str(), "id");
        assert_eq!(column.type_name().as_str(), "bigint");
        assert_eq!(column.nullability(), PgNullability::NotNull);
        assert_eq!(column.identity(), Some(PgIdentityKind::Always));
        Ok(())
    }

    #[test]
    fn stores_default_and_generated_labels() -> Result<(), PgColumnError> {
        let column = PgColumn::with_built_in_type(
            PgColumnName::new("created_at")?,
            PgBuiltInType::TimestampTz,
        )
        .with_default(PgColumnDefault::new("now()")?)
        .with_generated(PgGeneratedKind::Stored);

        assert_eq!(column.default().map(PgColumnDefault::as_str), Some("now()"));
        assert_eq!(column.generated(), Some(PgGeneratedKind::Stored));
        assert_eq!(PgNullability::NotNull.to_string(), "NOT NULL");
        Ok(())
    }

    #[test]
    fn parses_nullability_labels() -> Result<(), PgColumnError> {
        assert_eq!(
            "nullable".parse::<PgNullability>()?,
            PgNullability::Nullable
        );
        assert_eq!("not null".parse::<PgNullability>()?, PgNullability::NotNull);
        assert!(PgNullability::Nullable.is_nullable());
        Ok(())
    }
}