Files
komp_ac/common/src/decimal.rs
2026-08-24 21:31:06 +02:00

107 lines
3.3 KiB
Rust

// 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<Decimal, String> {
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}");
}
}
}