move decimal fn to common and reuse it with client and server

This commit is contained in:
Priec
2026-08-07 19:34:03 +02:00
parent 9ef28630dc
commit 251b1ae13d
6 changed files with 117 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
prost-types = { workspace = true }
rust_decimal = { workspace = true }
tonic = "0.14.6"
prost = "0.14.4"

112
common/src/decimal.rs Normal file
View File

@@ -0,0 +1,112 @@
// 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 `data_type` spellings `GetTableStructure` reports for a decimal
/// column: `NUMERIC` (from `numeric` and `money`), `NUMERIC(p)` and
/// `NUMERIC(p,s)` (from `decimal(p,s)`).
pub fn is_decimal_data_type(data_type: &str) -> bool {
data_type
.trim()
.to_ascii_uppercase()
.starts_with(DECIMAL_DATA_TYPE_PREFIX)
}
const DECIMAL_DATA_TYPE_PREFIX: &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_covers_every_numeric_spelling() {
for data_type in ["NUMERIC", "NUMERIC(12)", "NUMERIC(12,3)", "numeric(12,3)"] {
assert!(is_decimal_data_type(data_type), "missed {data_type}");
}
for data_type in ["TEXT", "INT8", "TIMESTAMPTZ", "VARCHAR(255)", ""] {
assert!(!is_decimal_data_type(data_type), "matched {data_type}");
}
}
}

View File

@@ -1,6 +1,7 @@
// common/src/lib.rs
pub mod search;
pub mod decimal;
pub mod grpc_error;
pub mod relationship;