107 lines
3.3 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|