siderite_validation/types/
decimal.rs1use 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#[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
36pub type UnboundedDecimal = Decimal<{ u32::MAX }, 28>;
38
39impl<const MAX_DIGITS: u32, const DECIMAL_PLACES: u32> Decimal<MAX_DIGITS, DECIMAL_PLACES> {
40 pub fn new(value: RawDecimal) -> Result<Self, FieldError> {
42 rules::decimal_digits(value, MAX_DIGITS, DECIMAL_PLACES)?;
43 Ok(Self(value))
44 }
45
46 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 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}