From 251b1ae13d44199e0f96326a30df8319f30f4096 Mon Sep 17 00:00:00 2001 From: Priec Date: Fri, 7 Aug 2026 19:34:03 +0200 Subject: [PATCH] move decimal fn to common and reuse it with client and server --- Cargo.lock | 1 + client | 2 +- common/Cargo.toml | 1 + common/src/decimal.rs | 112 ++++++++++++++++++++++++++++++++++++++++++ common/src/lib.rs | 1 + server | 2 +- 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 common/src/decimal.rs diff --git a/Cargo.lock b/Cargo.lock index e39183a2..ce474d7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,6 +1234,7 @@ dependencies = [ "prost", "prost-build", "prost-types", + "rust_decimal", "serde", "serde_json", "tantivy", diff --git a/client b/client index 1133e1b3..fe39e0f5 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 1133e1b36852caa5149d359786f03e498438a018 +Subproject commit fe39e0f57401717f344e06d6273222143113b999 diff --git a/common/Cargo.toml b/common/Cargo.toml index 88908c6a..a6c2f4e1 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] prost-types = { workspace = true } +rust_decimal = { workspace = true } tonic = "0.14.6" prost = "0.14.4" diff --git a/common/src/decimal.rs b/common/src/decimal.rs new file mode 100644 index 00000000..00d1ac0e --- /dev/null +++ b/common/src/decimal.rs @@ -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 { + 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}"); + } + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 44154e1c..fa2b7538 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,6 +1,7 @@ // common/src/lib.rs pub mod search; +pub mod decimal; pub mod grpc_error; pub mod relationship; diff --git a/server b/server index e242ebec..48c98273 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit e242ebec9482e34b5dfb4fd0ae359b842d575cf2 +Subproject commit 48c9827310557575e0c139887b1df00a2a8f3a50