// common/src/decimal.rs //! //! The one canonical decimal spelling shared by the client and the server. //! //! Every `NUMERIC` column travels the wire as a string so no value passes //! through `f64`. Both ends parse that string with [`parse_decimal_exact`], so //! the client rejects exactly what the server would reject and a value that //! parses locally is guaranteed to be accepted. use rust_decimal::Decimal; /// Parses a decimal written in canonical base-10 notation. /// /// Rejects anything ambiguous or non-finite: exponents (`1e2`), grouping /// (`1,00`), a leading `+`, surrounding whitespace, `NaN`/`inf`, and values /// outside [`Decimal`]'s range. pub fn parse_decimal_exact(value: &str) -> Result { if value.is_empty() || value.len() > 128 { return Err("Decimal must contain between 1 and 128 characters".to_string()); } let unsigned = value.strip_prefix('-').unwrap_or(value); if unsigned.is_empty() { return Err("Decimal must contain digits".to_string()); } let mut parts = unsigned.split('.'); let integer = parts.next().unwrap_or_default(); let fraction = parts.next(); if parts.next().is_some() || integer.is_empty() || !integer.bytes().all(|byte| byte.is_ascii_digit()) || fraction.is_some_and(|fraction| { fraction.is_empty() || !fraction.bytes().all(|byte| byte.is_ascii_digit()) }) { return Err("Decimal must use canonical base-10 notation".to_string()); } Decimal::from_str_exact(value).map_err(|error| error.to_string()) } /// True for the `NUMERIC` data type reported by `GetTableStructure`. pub fn is_decimal_data_type(data_type: &str) -> bool { data_type.trim().eq_ignore_ascii_case(DECIMAL_DATA_TYPE) } const DECIMAL_DATA_TYPE: &str = "NUMERIC"; #[cfg(test)] mod tests { use super::*; #[test] fn canonical_parser_rejects_ambiguous_or_non_finite_input() { for value in [ "", " 1", "1 ", "1,00", "1_000", "+1", "1e2", "NaN", "inf", "+inf", "--1", "-", ".5", "1.", "1.2.3", "79228162514264337593543950336", "8000000000000000000000000000.1", "0.00000000000000000000000000001", ] { assert!( parse_decimal_exact(value).is_err(), "unexpectedly accepted {value:?}" ); } } #[test] fn canonical_parser_preserves_the_written_scale() { for value in [ "0", "-0.01", "12.50", "12.500", "123456789012345678901.25", "-79228162514264337593543950335", ] { assert_eq!( parse_decimal_exact(value).unwrap().to_string(), value, "round trip changed {value:?}" ); } } #[test] fn decimal_data_type_matches_only_unconstrained_numeric() { assert!(is_decimal_data_type("NUMERIC")); assert!(is_decimal_data_type("numeric")); for data_type in ["NUMERIC(12)", "NUMERIC(12,3)", "TEXT", "INT8", ""] { assert!(!is_decimal_data_type(data_type), "matched {data_type}"); } } }