move decimal fn to common and reuse it with client and server
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1234,6 +1234,7 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
"prost-build",
|
"prost-build",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tantivy",
|
"tantivy",
|
||||||
|
|||||||
2
client
2
client
Submodule client updated: 1133e1b368...fe39e0f574
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
prost-types = { workspace = true }
|
prost-types = { workspace = true }
|
||||||
|
rust_decimal = { workspace = true }
|
||||||
|
|
||||||
tonic = "0.14.6"
|
tonic = "0.14.6"
|
||||||
prost = "0.14.4"
|
prost = "0.14.4"
|
||||||
|
|||||||
112
common/src/decimal.rs
Normal file
112
common/src/decimal.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// common/src/lib.rs
|
// common/src/lib.rs
|
||||||
|
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod decimal;
|
||||||
pub mod grpc_error;
|
pub mod grpc_error;
|
||||||
pub mod relationship;
|
pub mod relationship;
|
||||||
|
|
||||||
|
|||||||
2
server
2
server
Submodule server updated: e242ebec94...48c9827310
Reference in New Issue
Block a user