Skip to main content

siderite_validation/types/
decimal.rs

1//! Arbitrary-precision decimal newtype wrapping [`rust_decimal::Decimal`].
2
3use std::str::FromStr;
4
5use rust_decimal::Decimal as RawDecimal;
6use serde::{Deserialize, Serialize};
7use serde_json::{Number, Value};
8
9use crate::context::ValidationContext;
10use crate::dump::Dump;
11use crate::error::FieldError;
12use crate::rules;
13use crate::schema::{Schema, SchemaObject, SchemaRegistry};
14use crate::validate::{Validate, json_type};
15
16/// Decimal number with at most `MAX_DIGITS` significant digits and at most
17/// `DECIMAL_PLACES` digits after the decimal point.
18///
19/// **Why strings in strict mode.** JSON numbers are IEEE-754 floats in
20/// `serde_json` (this crate does not enable `arbitrary_precision`), so values
21/// such as `1.1` cannot be represented exactly. Strict mode therefore accepts
22/// only JSON strings; lax mode also accepts numbers and converts them through
23/// `f64`, which may round.
24///
25/// Trailing zeros are ignored when checking digits, matching Pydantic
26/// (`1.20` satisfies `DECIMAL_PLACES = 1`). Unlike Pydantic, integer digits
27/// are not additionally capped at `MAX_DIGITS - DECIMAL_PLACES`.
28///
29/// The JSON Schema is `{"type":"string","format":"decimal"}`. `format` is an
30/// application hint (not a JSON Schema 2020-12 built-in); a `pattern` is
31/// omitted because [`rust_decimal`] also accepts scientific notation.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
33#[serde(try_from = "String", into = "String")]
34pub struct Decimal<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32>(RawDecimal);
35
36/// Decimal constrained only by rust_decimal's maximum scale of 28.
37pub type UnboundedDecimal = Decimal<{ u32::MAX }, 28>;
38
39impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Decimal<MAX_DIGITS, DECIMAL_PLACES> {
40    /// Validate digit constraints and wrap `value`.
41    pub fn new(value: RawDecimal) -> Result<Self, FieldError> {
42        rules::decimal_digits(value, MAX_DIGITS, DECIMAL_PLACES)?;
43        Ok(Self(value))
44    }
45
46    /// Parse a decimal string and validate digit constraints.
47    pub fn parse(value: &str) -> Result<Self, FieldError> {
48        let parsed = RawDecimal::from_str(value)
49            .map_err(|_| FieldError::new("decimal_parsing", "input is not a valid decimal"))?;
50        Self::new(parsed)
51    }
52}
53
54impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Decimal<MAX_DIGITS, DECIMAL_PLACES> {
55    /// Unwrap the inner decimal.
56    pub fn into_inner(self) -> RawDecimal {
57        self.0
58    }
59}
60
61impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> AsRef<RawDecimal>
62    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
63{
64    fn as_ref(&self) -> &RawDecimal {
65        &self.0
66    }
67}
68
69impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> std::ops::Deref
70    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
71{
72    type Target = RawDecimal;
73
74    fn deref(&self) -> &Self::Target {
75        &self.0
76    }
77}
78
79impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> std::fmt::Display
80    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
81{
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        self.0.fmt(f)
84    }
85}
86
87impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> From<Decimal<MAX_DIGITS, DECIMAL_PLACES>>
88    for RawDecimal
89{
90    fn from(value: Decimal<MAX_DIGITS, DECIMAL_PLACES>) -> Self {
91        value.0
92    }
93}
94
95impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> TryFrom<RawDecimal>
96    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
97{
98    type Error = FieldError;
99
100    fn try_from(value: RawDecimal) -> Result<Self, Self::Error> {
101        Self::new(value)
102    }
103}
104
105impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> TryFrom<String>
106    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
107{
108    type Error = FieldError;
109
110    fn try_from(value: String) -> Result<Self, Self::Error> {
111        Self::parse(&value)
112    }
113}
114
115impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> TryFrom<&str>
116    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
117{
118    type Error = FieldError;
119
120    fn try_from(value: &str) -> Result<Self, Self::Error> {
121        Self::parse(value)
122    }
123}
124
125impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> From<Decimal<MAX_DIGITS, DECIMAL_PLACES>>
126    for String
127{
128    fn from(value: Decimal<MAX_DIGITS, DECIMAL_PLACES>) -> Self {
129        value.0.to_string()
130    }
131}
132
133fn number_to_decimal(n: &Number) -> Result<RawDecimal, FieldError> {
134    if let Some(i) = n.as_i64() {
135        return Ok(RawDecimal::from(i));
136    }
137    if let Some(u) = n.as_u64() {
138        return Ok(RawDecimal::from(u));
139    }
140    if let Some(f) = n.as_f64() {
141        return RawDecimal::from_f64_retain(f)
142            .ok_or_else(|| FieldError::new("decimal_parsing", "input is not a valid decimal"));
143    }
144    Err(FieldError::new(
145        "decimal_parsing",
146        "input is not a valid decimal",
147    ))
148}
149
150impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Validate
151    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
152{
153    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
154        if input.is_string() {
155            if super::prepare_as_string(input, ctx) {
156                ctx.check(Self::parse(input.as_str().unwrap_or_default()).map(|_| ()));
157            }
158            return;
159        }
160        if let Some(n) = input.as_number().cloned() {
161            if ctx.is_strict() {
162                let other = input.clone();
163                ctx.error(
164                    "decimal_type",
165                    format!("expected a decimal string, got {}", json_type(&other)),
166                );
167                return;
168            }
169            match number_to_decimal(&n) {
170                Ok(parsed) => {
171                    ctx.check(Self::new(parsed).map(|_| ()));
172                    *input = Value::String(parsed.to_string());
173                }
174                Err(error) => ctx.push(error),
175            }
176            return;
177        }
178        let other = input.clone();
179        let expected = if ctx.is_strict() {
180            "a decimal string"
181        } else {
182            "a decimal string or number"
183        };
184        ctx.error(
185            "decimal_type",
186            format!("expected {expected}, got {}", json_type(&other)),
187        );
188    }
189}
190
191impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Schema
192    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
193{
194    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
195        SchemaObject::of_type("string").with("format", "decimal")
196    }
197}
198
199impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Dump
200    for Decimal<MAX_DIGITS, DECIMAL_PLACES>
201{
202}
203
204#[cfg(test)]
205mod tests {
206    #![allow(clippy::unwrap_used)]
207
208    use super::*;
209    use crate::context::ValidationContext;
210    use crate::dump::{Dump, DumpOptions};
211    use crate::schema::schema_for;
212    use crate::types::prepare_codes;
213    use serde_json::json;
214
215    type Money = Decimal<5, 2>;
216
217    #[test]
218    fn decimal_construction() {
219        let value = Money::parse("123.45").unwrap();
220        assert_eq!(value.to_string(), "123.45");
221        assert_eq!(Money::parse("1.20").unwrap().to_string(), "1.20");
222        assert_eq!(
223            Money::parse("1.234").unwrap_err().code,
224            "decimal_max_places"
225        );
226        assert_eq!(
227            Money::parse("12345.6").unwrap_err().code,
228            "decimal_max_digits"
229        );
230        assert_eq!(Money::parse("nope").unwrap_err().code, "decimal_parsing");
231        assert!(UnboundedDecimal::parse("123").is_ok());
232    }
233
234    #[test]
235    fn decimal_serde_round_trip_and_reject() {
236        let value = Money::parse("12.5").unwrap();
237        let encoded = serde_json::to_string(&value).unwrap();
238        assert_eq!(encoded, "\"12.5\"");
239        let round: Money = serde_json::from_str(&encoded).unwrap();
240        assert_eq!(round, value);
241        assert!(serde_json::from_str::<Money>("\"1.234\"").is_err());
242        assert!(serde_json::from_str::<Money>("1.25").is_err());
243    }
244
245    #[test]
246    fn decimal_prepare_lax_and_strict() {
247        let (out, codes) = prepare_codes::<Money>(json!("1.5"), ValidationContext::new());
248        assert!(codes.is_empty());
249        assert_eq!(out, json!("1.5"));
250
251        let (out, codes) = prepare_codes::<Money>(json!(2), ValidationContext::new());
252        assert!(codes.is_empty());
253        assert_eq!(out, json!("2"));
254
255        assert_eq!(
256            prepare_codes::<Money>(json!(1.5), ValidationContext::new().with_strict(true)).1,
257            ["decimal_type"]
258        );
259        assert_eq!(
260            prepare_codes::<Money>(json!(true), ValidationContext::new()).1,
261            ["decimal_type"]
262        );
263        assert_eq!(
264            prepare_codes::<Money>(json!("nope"), ValidationContext::new()).1,
265            ["decimal_parsing"]
266        );
267        assert_eq!(
268            prepare_codes::<Money>(json!("1.234"), ValidationContext::new()).1,
269            ["decimal_max_places"]
270        );
271    }
272
273    #[test]
274    fn decimal_schema_dump_and_parse_value() {
275        assert_eq!(
276            schema_for::<Money>().0.into_value(),
277            json!({"type": "string", "format": "decimal"})
278        );
279        let value = Money::parse("3.14").unwrap();
280        assert_eq!(value.dump(&DumpOptions::new()).unwrap(), json!("3.14"));
281        let parsed = crate::parse_value::<Money>(json!(12), ValidationContext::new()).unwrap();
282        assert_eq!(parsed.to_string(), "12");
283        let parsed = crate::parse_value::<Money>(json!("9.9"), ValidationContext::new()).unwrap();
284        assert_eq!(parsed.to_string(), "9.9");
285        let err =
286            crate::parse_value::<Money>(json!(1.5), ValidationContext::new().with_strict(true))
287                .unwrap_err();
288        assert_eq!(err.errors[0].code, "decimal_type");
289    }
290}