use-pg-type 0.1.0

PostgreSQL type primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// A PostgreSQL type name label.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgTypeName(String);

impl PgTypeName {
    /// Creates a PostgreSQL type name label.
    ///
    /// # Errors
    ///
    /// Returns [`PgTypeError`] when the label is empty or contains control characters.
    pub fn new(input: impl AsRef<str>) -> Result<Self, PgTypeError> {
        validate_type_label(input.as_ref()).map(|value| Self(value.to_owned()))
    }

    /// Creates the canonical type name for a built-in type.
    #[must_use]
    pub fn built_in(ty: PgBuiltInType) -> Self {
        Self(ty.as_str().to_owned())
    }

    /// Creates an array-like type label from an element type name.
    #[must_use]
    pub fn array_of(element: &Self) -> Self {
        Self(format!("{}[]", element.as_str()))
    }

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

    /// Returns `true` when the label uses PostgreSQL array suffix syntax.
    #[must_use]
    pub fn is_array_label(&self) -> bool {
        self.0.ends_with("[]")
    }
}

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

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

impl FromStr for PgTypeName {
    type Err = PgTypeError;

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

impl TryFrom<&str> for PgTypeName {
    type Error = PgTypeError;

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

/// Broad PostgreSQL type categories.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgTypeCategory {
    #[default]
    UserDefined,
    Boolean,
    Numeric,
    String,
    Binary,
    DateTime,
    Uuid,
    Json,
    Network,
    Array,
    Enum,
    Composite,
    Domain,
    Range,
    Pseudo,
}

impl PgTypeCategory {
    /// Returns a stable lowercase category label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::UserDefined => "user-defined",
            Self::Boolean => "boolean",
            Self::Numeric => "numeric",
            Self::String => "string",
            Self::Binary => "binary",
            Self::DateTime => "date-time",
            Self::Uuid => "uuid",
            Self::Json => "json",
            Self::Network => "network",
            Self::Array => "array",
            Self::Enum => "enum",
            Self::Composite => "composite",
            Self::Domain => "domain",
            Self::Range => "range",
            Self::Pseudo => "pseudo",
        }
    }
}

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

/// Common PostgreSQL built-in type labels.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgBuiltInType {
    #[default]
    Text,
    Bool,
    SmallInt,
    Integer,
    BigInt,
    Numeric,
    Real,
    DoublePrecision,
    Varchar,
    Char,
    Bytea,
    Date,
    Time,
    Timestamp,
    TimestampTz,
    Uuid,
    Json,
    Jsonb,
    Inet,
    Cidr,
    Macaddr,
    Macaddr8,
    Array,
}

impl PgBuiltInType {
    /// Returns the canonical PostgreSQL type spelling.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Text => "text",
            Self::Bool => "boolean",
            Self::SmallInt => "smallint",
            Self::Integer => "integer",
            Self::BigInt => "bigint",
            Self::Numeric => "numeric",
            Self::Real => "real",
            Self::DoublePrecision => "double precision",
            Self::Varchar => "character varying",
            Self::Char => "character",
            Self::Bytea => "bytea",
            Self::Date => "date",
            Self::Time => "time",
            Self::Timestamp => "timestamp",
            Self::TimestampTz => "timestamp with time zone",
            Self::Uuid => "uuid",
            Self::Json => "json",
            Self::Jsonb => "jsonb",
            Self::Inet => "inet",
            Self::Cidr => "cidr",
            Self::Macaddr => "macaddr",
            Self::Macaddr8 => "macaddr8",
            Self::Array => "array",
        }
    }

    /// Returns the broad type category.
    #[must_use]
    pub const fn category(self) -> PgTypeCategory {
        match self {
            Self::Bool => PgTypeCategory::Boolean,
            Self::SmallInt
            | Self::Integer
            | Self::BigInt
            | Self::Numeric
            | Self::Real
            | Self::DoublePrecision => PgTypeCategory::Numeric,
            Self::Text | Self::Varchar | Self::Char => PgTypeCategory::String,
            Self::Bytea => PgTypeCategory::Binary,
            Self::Date | Self::Time | Self::Timestamp | Self::TimestampTz => {
                PgTypeCategory::DateTime
            }
            Self::Uuid => PgTypeCategory::Uuid,
            Self::Json | Self::Jsonb => PgTypeCategory::Json,
            Self::Inet | Self::Cidr | Self::Macaddr | Self::Macaddr8 => PgTypeCategory::Network,
            Self::Array => PgTypeCategory::Array,
        }
    }
}

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

impl FromStr for PgBuiltInType {
    type Err = PgTypeError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_type_label(input)?.as_str() {
            "bool" | "boolean" => Ok(Self::Bool),
            "smallint" | "int2" => Ok(Self::SmallInt),
            "integer" | "int" | "int4" => Ok(Self::Integer),
            "bigint" | "int8" => Ok(Self::BigInt),
            "numeric" | "decimal" => Ok(Self::Numeric),
            "real" | "float4" => Ok(Self::Real),
            "double precision" | "float8" => Ok(Self::DoublePrecision),
            "text" => Ok(Self::Text),
            "varchar" | "character varying" => Ok(Self::Varchar),
            "char" | "character" => Ok(Self::Char),
            "bytea" => Ok(Self::Bytea),
            "date" => Ok(Self::Date),
            "time" | "time without time zone" => Ok(Self::Time),
            "timestamp" | "timestamp without time zone" => Ok(Self::Timestamp),
            "timestamptz" | "timestamp with time zone" => Ok(Self::TimestampTz),
            "uuid" => Ok(Self::Uuid),
            "json" => Ok(Self::Json),
            "jsonb" => Ok(Self::Jsonb),
            "inet" => Ok(Self::Inet),
            "cidr" => Ok(Self::Cidr),
            "macaddr" => Ok(Self::Macaddr),
            "macaddr8" => Ok(Self::Macaddr8),
            "array" | "anyarray" => Ok(Self::Array),
            _ => Err(PgTypeError::UnknownBuiltInType),
        }
    }
}

impl TryFrom<&str> for PgBuiltInType {
    type Error = PgTypeError;

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

/// Optional primitive wrapper for a PostgreSQL type OID.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgTypeOid(u32);

impl PgTypeOid {
    /// Creates an OID wrapper.
    ///
    /// # Errors
    ///
    /// Returns [`PgTypeError::InvalidOid`] when `value` is zero.
    pub const fn new(value: u32) -> Result<Self, PgTypeError> {
        if value == 0 {
            Err(PgTypeError::InvalidOid)
        } else {
            Ok(Self(value))
        }
    }

    /// Returns the raw OID value.
    #[must_use]
    pub const fn get(self) -> u32 {
        self.0
    }
}

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

/// Error returned when PostgreSQL type metadata is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PgTypeError {
    Empty,
    ControlCharacter,
    UnknownBuiltInType,
    InvalidOid,
}

impl fmt::Display for PgTypeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("PostgreSQL type label cannot be empty"),
            Self::ControlCharacter => {
                formatter.write_str("PostgreSQL type label cannot contain control characters")
            }
            Self::UnknownBuiltInType => {
                formatter.write_str("unknown PostgreSQL built-in type label")
            }
            Self::InvalidOid => {
                formatter.write_str("PostgreSQL type OID must be greater than zero")
            }
        }
    }
}

impl Error for PgTypeError {}

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

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

#[cfg(test)]
mod tests {
    use super::{PgBuiltInType, PgTypeCategory, PgTypeError, PgTypeName, PgTypeOid};

    #[test]
    fn parses_common_built_in_types() -> Result<(), PgTypeError> {
        assert_eq!("bool".parse::<PgBuiltInType>()?, PgBuiltInType::Bool);
        assert_eq!("int4".parse::<PgBuiltInType>()?, PgBuiltInType::Integer);
        assert_eq!(
            "double precision".parse::<PgBuiltInType>()?,
            PgBuiltInType::DoublePrecision
        );
        assert_eq!(
            "timestamptz".parse::<PgBuiltInType>()?,
            PgBuiltInType::TimestampTz
        );
        assert_eq!("jsonb".parse::<PgBuiltInType>()?, PgBuiltInType::Jsonb);
        Ok(())
    }

    #[test]
    fn renders_canonical_labels_and_categories() {
        assert_eq!(PgBuiltInType::Varchar.to_string(), "character varying");
        assert_eq!(PgBuiltInType::Inet.category(), PgTypeCategory::Network);
        assert_eq!(PgTypeCategory::Array.to_string(), "array");
    }

    #[test]
    fn creates_type_names_and_arrays() {
        let text = PgTypeName::built_in(PgBuiltInType::Text);
        let array = PgTypeName::array_of(&text);
        assert_eq!(text.as_str(), "text");
        assert_eq!(array.to_string(), "text[]");
        assert!(array.is_array_label());
        assert_eq!(PgTypeName::new(""), Err(PgTypeError::Empty));
    }

    #[test]
    fn wraps_oids_without_binding_catalog_meaning() -> Result<(), PgTypeError> {
        let oid = PgTypeOid::new(23)?;
        assert_eq!(oid.get(), 23);
        assert_eq!(oid.to_string(), "23");
        assert_eq!(PgTypeOid::new(0), Err(PgTypeError::InvalidOid));
        Ok(())
    }
}