rullst-orm 12.2.0

Fast, un-opinionated, database-agnostic ORM and query builder for Rust
Documentation
use crate::Error;

/// Maximum number of labels accepted by the native-enum schema contract.
pub const MAX_DATABASE_ENUM_VARIANTS: usize = 64;
/// PostgreSQL enum labels are limited by `NAMEDATALEN`; keep the portable
/// contract within the default 63-byte boundary.
pub const MAX_DATABASE_ENUM_LABEL_BYTES: usize = 63;

/// Closed metadata generated by [`crate::Enum`] for database-backed enums.
///
/// Implementations must use a single unqualified SQL type identifier and a
/// non-empty, ordered list of unique labels. [`Blueprint::native_enum`](super::Blueprint::native_enum)
/// validates manually written implementations before emitting SQL.
pub trait DatabaseEnum {
    /// PostgreSQL type identifier. MySQL/MariaDB use the labels inline.
    const TYPE_NAME: &'static str;
    /// Ordered wire labels used by DDL, SQLx codecs, Serde and `Display`.
    const VARIANTS: &'static [&'static str];
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct NativeEnumDefinition {
    pub type_name: &'static str,
    pub variants: &'static [&'static str],
}

pub(super) fn validated_definition<E: DatabaseEnum>() -> Result<NativeEnumDefinition, Error> {
    validate_native_enum(E::TYPE_NAME, E::VARIANTS)?;
    Ok(NativeEnumDefinition {
        type_name: E::TYPE_NAME,
        variants: E::VARIANTS,
    })
}

pub(super) fn validate_native_enum(type_name: &str, variants: &[&str]) -> Result<(), Error> {
    let type_name_bytes = type_name.as_bytes();
    if type_name_bytes.is_empty()
        || type_name_bytes.len() > MAX_DATABASE_ENUM_LABEL_BYTES
        || !(type_name_bytes[0].is_ascii_alphabetic() || type_name_bytes[0] == b'_')
        || !type_name_bytes
            .iter()
            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
    {
        return Err(Error::Validation(format!(
            "database enum type names must be 1-{MAX_DATABASE_ENUM_LABEL_BYTES} byte unqualified ASCII identifiers"
        )));
    }
    if variants.is_empty() || variants.len() > MAX_DATABASE_ENUM_VARIANTS {
        return Err(Error::Validation(format!(
            "database enums require 1-{MAX_DATABASE_ENUM_VARIANTS} variants"
        )));
    }

    let mut seen = std::collections::BTreeSet::new();
    for variant in variants {
        if variant.is_empty()
            || variant.len() > MAX_DATABASE_ENUM_LABEL_BYTES
            || !variant
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b' '))
        {
            return Err(Error::Validation(format!(
                "database enum labels must be 1-{MAX_DATABASE_ENUM_LABEL_BYTES} ASCII letters, digits, spaces, underscores or hyphens"
            )));
        }
        if !seen.insert(*variant) {
            return Err(Error::Validation(
                "database enum labels must be unique".to_string(),
            ));
        }
    }
    Ok(())
}

pub(super) fn quoted_label(value: &str) -> String {
    format!("'{}'", value.replace('\'', "''"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn enum_metadata_rejects_unsafe_or_ambiguous_shapes() {
        assert!(validate_native_enum("account_status", &["Active", "Paused"]).is_ok());
        assert!(validate_native_enum("public.account_status", &["Active"]).is_err());
        assert!(validate_native_enum("1account_status", &["Active"]).is_err());
        assert!(validate_native_enum("status;drop", &["Active"]).is_err());
        assert!(
            validate_native_enum(&"x".repeat(MAX_DATABASE_ENUM_LABEL_BYTES + 1), &["Active"])
                .is_err()
        );
        assert!(validate_native_enum("status", &[]).is_err());
        assert!(validate_native_enum("status", &["Active", "Active"]).is_err());
        assert!(validate_native_enum("status", &["line\nfeed"]).is_err());
        assert!(validate_native_enum("status", &["owner's"]).is_err());
        assert!(validate_native_enum("status", &["back\\slash"]).is_err());
        assert!(
            validate_native_enum("status", &[&"x".repeat(MAX_DATABASE_ENUM_LABEL_BYTES + 1)])
                .is_err()
        );
    }

    #[test]
    fn enum_labels_are_sql_quoted_defensively() {
        assert_eq!(quoted_label("awaiting_review"), "'awaiting_review'");
        assert_eq!(quoted_label("owner's"), "'owner''s'");
    }
}