Skip to main content

rullst_orm/schema/
enums.rs

1use crate::Error;
2
3/// Maximum number of labels accepted by the native-enum schema contract.
4pub const MAX_DATABASE_ENUM_VARIANTS: usize = 64;
5/// PostgreSQL enum labels are limited by `NAMEDATALEN`; keep the portable
6/// contract within the default 63-byte boundary.
7pub const MAX_DATABASE_ENUM_LABEL_BYTES: usize = 63;
8
9/// Closed metadata generated by [`crate::Enum`] for database-backed enums.
10///
11/// Implementations must use a single unqualified SQL type identifier and a
12/// non-empty, ordered list of unique labels. [`Blueprint::native_enum`](super::Blueprint::native_enum)
13/// validates manually written implementations before emitting SQL.
14pub trait DatabaseEnum {
15    /// PostgreSQL type identifier. MySQL/MariaDB use the labels inline.
16    const TYPE_NAME: &'static str;
17    /// Ordered wire labels used by DDL, SQLx codecs, Serde and `Display`.
18    const VARIANTS: &'static [&'static str];
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub(super) struct NativeEnumDefinition {
23    pub type_name: &'static str,
24    pub variants: &'static [&'static str],
25}
26
27pub(super) fn validated_definition<E: DatabaseEnum>() -> Result<NativeEnumDefinition, Error> {
28    validate_native_enum(E::TYPE_NAME, E::VARIANTS)?;
29    Ok(NativeEnumDefinition {
30        type_name: E::TYPE_NAME,
31        variants: E::VARIANTS,
32    })
33}
34
35pub(super) fn validate_native_enum(type_name: &str, variants: &[&str]) -> Result<(), Error> {
36    let type_name_bytes = type_name.as_bytes();
37    if type_name_bytes.is_empty()
38        || type_name_bytes.len() > MAX_DATABASE_ENUM_LABEL_BYTES
39        || !(type_name_bytes[0].is_ascii_alphabetic() || type_name_bytes[0] == b'_')
40        || !type_name_bytes
41            .iter()
42            .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
43    {
44        return Err(Error::Validation(format!(
45            "database enum type names must be 1-{MAX_DATABASE_ENUM_LABEL_BYTES} byte unqualified ASCII identifiers"
46        )));
47    }
48    if variants.is_empty() || variants.len() > MAX_DATABASE_ENUM_VARIANTS {
49        return Err(Error::Validation(format!(
50            "database enums require 1-{MAX_DATABASE_ENUM_VARIANTS} variants"
51        )));
52    }
53
54    let mut seen = std::collections::BTreeSet::new();
55    for variant in variants {
56        if variant.is_empty()
57            || variant.len() > MAX_DATABASE_ENUM_LABEL_BYTES
58            || !variant
59                .bytes()
60                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b' '))
61        {
62            return Err(Error::Validation(format!(
63                "database enum labels must be 1-{MAX_DATABASE_ENUM_LABEL_BYTES} ASCII letters, digits, spaces, underscores or hyphens"
64            )));
65        }
66        if !seen.insert(*variant) {
67            return Err(Error::Validation(
68                "database enum labels must be unique".to_string(),
69            ));
70        }
71    }
72    Ok(())
73}
74
75pub(super) fn quoted_label(value: &str) -> String {
76    format!("'{}'", value.replace('\'', "''"))
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn enum_metadata_rejects_unsafe_or_ambiguous_shapes() {
85        assert!(validate_native_enum("account_status", &["Active", "Paused"]).is_ok());
86        assert!(validate_native_enum("public.account_status", &["Active"]).is_err());
87        assert!(validate_native_enum("1account_status", &["Active"]).is_err());
88        assert!(validate_native_enum("status;drop", &["Active"]).is_err());
89        assert!(
90            validate_native_enum(&"x".repeat(MAX_DATABASE_ENUM_LABEL_BYTES + 1), &["Active"])
91                .is_err()
92        );
93        assert!(validate_native_enum("status", &[]).is_err());
94        assert!(validate_native_enum("status", &["Active", "Active"]).is_err());
95        assert!(validate_native_enum("status", &["line\nfeed"]).is_err());
96        assert!(validate_native_enum("status", &["owner's"]).is_err());
97        assert!(validate_native_enum("status", &["back\\slash"]).is_err());
98        assert!(
99            validate_native_enum("status", &[&"x".repeat(MAX_DATABASE_ENUM_LABEL_BYTES + 1)])
100                .is_err()
101        );
102    }
103
104    #[test]
105    fn enum_labels_are_sql_quoted_defensively() {
106        assert_eq!(quoted_label("awaiting_review"), "'awaiting_review'");
107        assert_eq!(quoted_label("owner's"), "'owner''s'");
108    }
109}