fraiseql-core 2.12.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Rich scalar type validators for specialized data formats.
//!
//! This module provides validators for common structured data types like emails,
//! phone numbers, IBANs, VINs, and country codes.

use std::sync::LazyLock;

use regex::Regex;

use crate::validation::patterns;

/// Email format regex — canonical pattern from [`patterns::EMAIL`].
static EMAIL_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(patterns::EMAIL).expect("email regex is valid"));

// International phone: +1-999-999-9999 or +999999999999, etc. — lenient pattern from
// `patterns::PHONE_LENIENT`
static PHONE_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(patterns::PHONE_LENIENT).expect("phone regex is valid"));

// VIN: 17 alphanumeric characters (no I, O, Q)
static VIN_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[A-HJ-NPR-Z0-9]{17}$").expect("VIN regex is valid"));

// Country code: ISO 3166-1 alpha-2 (2 letters)
static COUNTRY_CODE_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[A-Z]{2}$").expect("country code regex is valid"));

/// Email address validator.
pub struct EmailValidator;

impl EmailValidator {
    /// Validate an email address format.
    ///
    /// Uses a practical regex pattern that handles most common email formats.
    /// Note: This validates format only, not domain existence (use async validator for that).
    pub fn validate(value: &str) -> bool {
        !value.is_empty() && value.len() <= 254 && EMAIL_REGEX.is_match(value)
    }

    /// Return the standard validation error message for an invalid email.
    #[must_use]
    pub const fn error_message() -> &'static str {
        "Invalid email format"
    }
}

/// International phone number validator.
pub struct PhoneNumberValidator;

impl PhoneNumberValidator {
    /// Validate an international phone number format.
    ///
    /// Accepts formats like:
    /// - +1234567890
    /// - +1-234-567-8900
    /// - 1234567890
    ///
    /// The pattern allows +1 to +999 country codes followed by 1-14 additional digits.
    pub fn validate(value: &str) -> bool {
        !value.is_empty() && value.len() <= 20 && PHONE_REGEX.is_match(value)
    }

    /// Return the standard validation error message for an invalid phone number.
    #[must_use]
    pub const fn error_message() -> &'static str {
        "Invalid phone number format"
    }
}

/// VIN (Vehicle Identification Number) validator.
pub struct VinValidator;

impl VinValidator {
    /// Validate a VIN format.
    ///
    /// A valid VIN is:
    /// - Exactly 17 characters
    /// - Only alphanumeric (no I, O, Q to avoid confusion with numbers)
    /// - Case insensitive
    ///
    /// Note: This validates format only, not checksum (different per manufacturer).
    pub fn validate(value: &str) -> bool {
        // VINs are always exactly 17 characters — reject anything else before
        // allocating the uppercase copy and running the regex.
        if value.len() != 17 {
            return false;
        }
        let value_upper = value.to_uppercase();
        VIN_REGEX.is_match(&value_upper)
    }

    /// Return the standard validation error message for an invalid VIN.
    #[must_use]
    pub const fn error_message() -> &'static str {
        "Invalid VIN format (must be 17 alphanumeric characters, excluding I, O, Q)"
    }
}

/// Country code validator (ISO 3166-1 alpha-2).
#[doc(hidden)] // Internal-pub: validation helper used by fraiseql-core's rich-scalars module; shape may change as the ISO table is reorganised.
pub struct CountryCodeValidator {
    valid_codes: std::collections::HashSet<&'static str>,
}

impl CountryCodeValidator {
    /// Create a new country code validator with all valid ISO codes.
    #[must_use]
    pub fn new() -> Self {
        let mut codes = std::collections::HashSet::new();
        // All ISO 3166-1 alpha-2 codes
        codes.insert("AD");
        codes.insert("AE");
        codes.insert("AF");
        codes.insert("AG");
        codes.insert("AI");
        codes.insert("AL");
        codes.insert("AM");
        codes.insert("AO");
        codes.insert("AQ");
        codes.insert("AR");
        codes.insert("AS");
        codes.insert("AT");
        codes.insert("AU");
        codes.insert("AW");
        codes.insert("AX");
        codes.insert("AZ");
        codes.insert("BA");
        codes.insert("BB");
        codes.insert("BD");
        codes.insert("BE");
        codes.insert("BF");
        codes.insert("BG");
        codes.insert("BH");
        codes.insert("BI");
        codes.insert("BJ");
        codes.insert("BL");
        codes.insert("BM");
        codes.insert("BN");
        codes.insert("BO");
        codes.insert("BQ");
        codes.insert("BR");
        codes.insert("BS");
        codes.insert("BT");
        codes.insert("BV");
        codes.insert("BW");
        codes.insert("BY");
        codes.insert("BZ");
        codes.insert("CA");
        codes.insert("CC");
        codes.insert("CD");
        codes.insert("CF");
        codes.insert("CG");
        codes.insert("CH");
        codes.insert("CI");
        codes.insert("CK");
        codes.insert("CL");
        codes.insert("CM");
        codes.insert("CN");
        codes.insert("CO");
        codes.insert("CR");
        codes.insert("CU");
        codes.insert("CV");
        codes.insert("CW");
        codes.insert("CX");
        codes.insert("CY");
        codes.insert("CZ");
        codes.insert("DE");
        codes.insert("DJ");
        codes.insert("DK");
        codes.insert("DM");
        codes.insert("DO");
        codes.insert("DZ");
        codes.insert("EC");
        codes.insert("EE");
        codes.insert("EG");
        codes.insert("EH");
        codes.insert("ER");
        codes.insert("ES");
        codes.insert("ET");
        codes.insert("FI");
        codes.insert("FJ");
        codes.insert("FK");
        codes.insert("FM");
        codes.insert("FO");
        codes.insert("FR");
        codes.insert("GA");
        codes.insert("GB");
        codes.insert("GD");
        codes.insert("GE");
        codes.insert("GF");
        codes.insert("GG");
        codes.insert("GH");
        codes.insert("GI");
        codes.insert("GL");
        codes.insert("GM");
        codes.insert("GN");
        codes.insert("GP");
        codes.insert("GQ");
        codes.insert("GR");
        codes.insert("GS");
        codes.insert("GT");
        codes.insert("GU");
        codes.insert("GW");
        codes.insert("GY");
        codes.insert("HK");
        codes.insert("HM");
        codes.insert("HN");
        codes.insert("HR");
        codes.insert("HT");
        codes.insert("HU");
        codes.insert("ID");
        codes.insert("IE");
        codes.insert("IL");
        codes.insert("IM");
        codes.insert("IN");
        codes.insert("IO");
        codes.insert("IQ");
        codes.insert("IR");
        codes.insert("IS");
        codes.insert("IT");
        codes.insert("JE");
        codes.insert("JM");
        codes.insert("JO");
        codes.insert("JP");
        codes.insert("KE");
        codes.insert("KG");
        codes.insert("KH");
        codes.insert("KI");
        codes.insert("KM");
        codes.insert("KN");
        codes.insert("KP");
        codes.insert("KR");
        codes.insert("KW");
        codes.insert("KY");
        codes.insert("KZ");
        codes.insert("LA");
        codes.insert("LB");
        codes.insert("LC");
        codes.insert("LI");
        codes.insert("LK");
        codes.insert("LR");
        codes.insert("LS");
        codes.insert("LT");
        codes.insert("LU");
        codes.insert("LV");
        codes.insert("LY");
        codes.insert("MA");
        codes.insert("MC");
        codes.insert("MD");
        codes.insert("ME");
        codes.insert("MF");
        codes.insert("MG");
        codes.insert("MH");
        codes.insert("MK");
        codes.insert("ML");
        codes.insert("MM");
        codes.insert("MN");
        codes.insert("MO");
        codes.insert("MP");
        codes.insert("MQ");
        codes.insert("MR");
        codes.insert("MS");
        codes.insert("MT");
        codes.insert("MU");
        codes.insert("MV");
        codes.insert("MW");
        codes.insert("MX");
        codes.insert("MY");
        codes.insert("MZ");
        codes.insert("NA");
        codes.insert("NC");
        codes.insert("NE");
        codes.insert("NF");
        codes.insert("NG");
        codes.insert("NI");
        codes.insert("NL");
        codes.insert("NO");
        codes.insert("NP");
        codes.insert("NR");
        codes.insert("NU");
        codes.insert("NZ");
        codes.insert("OM");
        codes.insert("PA");
        codes.insert("PE");
        codes.insert("PF");
        codes.insert("PG");
        codes.insert("PH");
        codes.insert("PK");
        codes.insert("PL");
        codes.insert("PM");
        codes.insert("PN");
        codes.insert("PR");
        codes.insert("PS");
        codes.insert("PT");
        codes.insert("PW");
        codes.insert("PY");
        codes.insert("QA");
        codes.insert("RE");
        codes.insert("RO");
        codes.insert("RS");
        codes.insert("RU");
        codes.insert("RW");
        codes.insert("SA");
        codes.insert("SB");
        codes.insert("SC");
        codes.insert("SD");
        codes.insert("SE");
        codes.insert("SG");
        codes.insert("SH");
        codes.insert("SI");
        codes.insert("SJ");
        codes.insert("SK");
        codes.insert("SL");
        codes.insert("SM");
        codes.insert("SN");
        codes.insert("SO");
        codes.insert("SR");
        codes.insert("SS");
        codes.insert("ST");
        codes.insert("SV");
        codes.insert("SX");
        codes.insert("SY");
        codes.insert("SZ");
        codes.insert("TC");
        codes.insert("TD");
        codes.insert("TF");
        codes.insert("TG");
        codes.insert("TH");
        codes.insert("TJ");
        codes.insert("TK");
        codes.insert("TL");
        codes.insert("TM");
        codes.insert("TN");
        codes.insert("TO");
        codes.insert("TR");
        codes.insert("TT");
        codes.insert("TV");
        codes.insert("TW");
        codes.insert("TZ");
        codes.insert("UA");
        codes.insert("UG");
        codes.insert("UM");
        codes.insert("US");
        codes.insert("UY");
        codes.insert("UZ");
        codes.insert("VA");
        codes.insert("VC");
        codes.insert("VE");
        codes.insert("VG");
        codes.insert("VI");
        codes.insert("VN");
        codes.insert("VU");
        codes.insert("WF");
        codes.insert("WS");
        codes.insert("YE");
        codes.insert("YT");
        codes.insert("ZA");
        codes.insert("ZM");
        codes.insert("ZW");
        Self { valid_codes: codes }
    }

    /// Validate a country code against ISO 3166-1 alpha-2 standard.
    pub fn validate(&self, value: &str) -> bool {
        let value_upper = value.to_uppercase();
        COUNTRY_CODE_REGEX.is_match(&value_upper) && self.valid_codes.contains(value_upper.as_str())
    }

    /// Return the standard validation error message for an invalid country code.
    #[must_use]
    pub const fn error_message() -> &'static str {
        "Invalid country code (must be ISO 3166-1 alpha-2)"
    }
}

impl Default for CountryCodeValidator {
    fn default() -> Self {
        Self::new()
    }
}