fhir_core/decimal.rs
1//! The FHIR `decimal` primitive.
2//!
3//! URL: <http://hl7.org/fhir/StructureDefinition/decimal>
4//!
5//! `decimal` is defined identically in R3, R4 and R5 — "a rational number
6//! with implicit precision" — so unlike the other primitives it is written
7//! once here and re-exported by each release's `types` module, alongside
8//! [`Coded`](crate::coded) and [`temporal`](crate::temporal).
9//!
10//! See spec 02 (R2.2) for why the representation is lexical.
11
12use ::serde::{Deserialize, Serialize};
13
14/// A rational number with implicit precision, used to represent measurement
15/// values and other quantities where the number of significant digits carries
16/// meaning.
17///
18/// # Precision is data
19///
20/// FHIR states that the precision of a decimal has significance: a laboratory
21/// result of `0.50` mmol/L claims two significant figures where `0.5` claims
22/// one, and a dose of `1.000` mg is a different assertion from `1.0` mg. This
23/// type therefore stores the **lexical form** it was given and emits it back
24/// unchanged (spec R2.2).
25///
26/// Backed by `f64` — which is what `serde_json::Number` is by default —
27/// `0.50` becomes `0.5`, `1.000` becomes `1.0`, and
28/// `12345678901234567890.5` becomes `1.2345678901234567e+19`. This crate
29/// therefore enables `serde_json/arbitrary_precision` **unconditionally**, so
30/// a `Number` carries the lexeme it was parsed from. Cargo features are
31/// additive and cannot be switched off by a dependent, which makes precision
32/// a guarantee rather than a default someone can lose.
33///
34/// The cost is real and worth stating: `arbitrary_precision` is global to the
35/// compiled binary, so every other crate's `serde_json::Number` in the same
36/// build also becomes lexeme-preserving, and `Number` arithmetic goes through
37/// `as_f64()`. For a library whose numbers are drug doses and lab results,
38/// that is the correct side to err on.
39///
40/// # Equality is lexical, ordering is numeric
41///
42/// `Decimal("1.0") != Decimal("1.00")`, because the two say different things
43/// about precision and must survive a round trip distinctly. They *compare*
44/// equal, because they denote the same quantity:
45///
46/// ```
47/// use fhir::decimal::Decimal;
48/// use std::cmp::Ordering;
49///
50/// let one_dp = Decimal::new("1.0").unwrap();
51/// let two_dp = Decimal::new("1.00").unwrap();
52/// assert_ne!(one_dp, two_dp);
53/// assert_eq!(one_dp.partial_cmp(&two_dp), Some(Ordering::Equal));
54/// ```
55///
56/// # JSON
57///
58/// The lexeme survives every serde path this crate uses — `from_str`,
59/// `from_slice`, `from_reader`, `from_value`, and through the
60/// `#[serde(flatten)]` that choice elements rely on. A `serde_json::Value`
61/// built in the same binary is likewise lexeme-preserving, so
62/// `json!(0.50) != json!(0.5)`, and a round-trip test comparing `Value`s can
63/// see precision loss rather than silently tolerating it (spec R13.3).
64///
65/// ```
66/// use fhir::decimal::Decimal;
67///
68/// let parsed: Decimal = ::serde_json::from_str("0.50").unwrap();
69/// assert_eq!(parsed.as_str(), "0.50");
70/// assert_eq!(::serde_json::to_string(&parsed).unwrap(), "0.50");
71/// ```
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Decimal(::serde_json::Number);
74
75/// The reason a string is not a FHIR `decimal`.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct DecimalError(String);
78
79impl std::fmt::Display for DecimalError {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 write!(f, "not a FHIR decimal: {:?}", self.0)
82 }
83}
84
85impl std::error::Error for DecimalError {}
86
87impl Decimal {
88 /// A decimal from its lexical form, checked against the FHIR `decimal`
89 /// production `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`.
90 ///
91 /// # Errors
92 ///
93 /// Returns [`DecimalError`] when the text is not a FHIR decimal.
94 pub fn new(lexeme: impl Into<String>) -> Result<Self, DecimalError> {
95 let lexeme = lexeme.into();
96 if !is_fhir_decimal(&lexeme) {
97 return Err(DecimalError(lexeme));
98 }
99 // Parsing the lexeme back through serde_json is what stores it: with
100 // `arbitrary_precision` a `Number` *is* its lexeme.
101 ::serde_json::from_str::<::serde_json::Number>(&lexeme)
102 .map(Decimal)
103 .map_err(|_| DecimalError(lexeme))
104 }
105
106 /// The stored lexical form, exactly as received.
107 #[must_use]
108 pub fn as_str(&self) -> &str {
109 self.0.as_str()
110 }
111
112 /// The value as an `f64`, which is lossy by definition — use it for
113 /// arithmetic, never for storage or comparison.
114 #[must_use]
115 pub fn as_f64(&self) -> f64 {
116 self.0.as_f64().unwrap_or(f64::NAN)
117 }
118
119 /// A decimal from an already-parsed [`serde_json::Number`], for callers
120 /// holding a [`serde_json::Value`]. Lossless, because this crate
121 /// guarantees `arbitrary_precision`.
122 #[must_use]
123 pub fn from_json_number(n: &::serde_json::Number) -> Self {
124 Decimal(n.clone())
125 }
126
127 /// The underlying `serde_json::Number`, for interoperating with code
128 /// that speaks `serde_json` directly.
129 #[must_use]
130 pub fn as_number(&self) -> &::serde_json::Number {
131 &self.0
132 }
133}
134
135impl std::fmt::Display for Decimal {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.write_str(self.0.as_str())
138 }
139}
140
141impl std::str::FromStr for Decimal {
142 type Err = DecimalError;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
145 Decimal::new(s)
146 }
147}
148
149/// Numeric ordering over lexically distinct values: `1.0` and `1.00` are the
150/// same quantity even though they are not the same assertion.
151impl PartialOrd for Decimal {
152 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
153 self.as_f64().partial_cmp(&other.as_f64())
154 }
155}
156
157impl Default for Decimal {
158 fn default() -> Self {
159 Decimal(::serde_json::Number::from(0))
160 }
161}
162
163/// `Decimal` validates its own lexeme (spec R2.6).
164///
165/// One impl for one shared type: were this written per release, compiling two
166/// releases together would be a conflicting-impl error.
167impl crate::validate::Validate for Decimal {
168 fn validate(&self) -> Vec<crate::validate::ValidationIssue> {
169 if is_fhir_decimal(self.as_str()) {
170 Vec::new()
171 } else {
172 // The path is the datatype's own label, which the deriving
173 // parent prefixes with the field name (e.g. `value.decimal`).
174 vec![crate::validate::ValidationIssue::new(
175 "decimal",
176 "must match the FHIR decimal production \
177 -?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?",
178 )]
179 }
180 }
181}
182
183/// The FHIR `decimal` lexical production:
184/// `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`.
185fn is_fhir_decimal(s: &str) -> bool {
186 let b = s.as_bytes();
187 let mut i = 0;
188 if i < b.len() && b[i] == b'-' {
189 i += 1;
190 }
191 // Integer part: `0` alone, or a run that does not lead with zero.
192 let start = i;
193 while i < b.len() && b[i].is_ascii_digit() {
194 i += 1;
195 }
196 if i == start {
197 return false;
198 }
199 if i - start > 1 && b[start] == b'0' {
200 return false;
201 }
202 // Optional fraction, at least one digit.
203 if i < b.len() && b[i] == b'.' {
204 i += 1;
205 let frac = i;
206 while i < b.len() && b[i].is_ascii_digit() {
207 i += 1;
208 }
209 if i == frac {
210 return false;
211 }
212 }
213 // Optional exponent, at least one digit.
214 if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
215 i += 1;
216 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
217 i += 1;
218 }
219 let exp = i;
220 while i < b.len() && b[i].is_ascii_digit() {
221 i += 1;
222 }
223 if i == exp {
224 return false;
225 }
226 }
227 i == b.len()
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_default() {
236 assert_eq!(Decimal::default().as_str(), "0");
237 }
238
239 #[test]
240 fn test_serde() {
241 let value: Decimal = ::serde_json::from_str("3.5").expect("from_str");
242 assert_eq!(::serde_json::to_string(&value).expect("to_string"), "3.5");
243 }
244
245 /// Spec 02 acceptance 2a: the values the `serde_json::Number`
246 /// representation silently altered.
247 #[test]
248 fn lexical_form_survives_a_round_trip() {
249 for input in [
250 "0.50",
251 "1.000",
252 "1e-7",
253 "-0.0001",
254 "0.1234567890123456789012345",
255 "12345678901234567890.5",
256 ] {
257 let parsed: Decimal =
258 ::serde_json::from_str(input).unwrap_or_else(|e| panic!("parse {input}: {e}"));
259 let out = ::serde_json::to_string(&parsed).expect("to_string");
260 assert_eq!(out, input, "{input} did not survive");
261 }
262 }
263
264 /// Spec 02 acceptance 2b.
265 #[test]
266 fn equality_is_lexical_and_ordering_is_numeric() {
267 let one_dp = Decimal::new("1.0").expect("valid");
268 let two_dp = Decimal::new("1.00").expect("valid");
269 assert_ne!(one_dp, two_dp);
270 assert_eq!(one_dp.partial_cmp(&two_dp), Some(std::cmp::Ordering::Equal));
271 assert!(Decimal::new("2").expect("valid") > one_dp);
272 }
273
274 #[test]
275 fn rejects_non_decimals() {
276 for bad in [
277 "", "-", ".5", "1.", "01", "1e", "1.2.3", " 1", "1 ", "NaN", "+1",
278 ] {
279 assert!(Decimal::new(bad).is_err(), "{bad:?} should be rejected");
280 }
281 }
282
283 #[test]
284 fn accepts_the_production() {
285 for good in [
286 "0", "-0", "1", "-1", "0.0", "1.5", "1e10", "1E+10", "-2.5e-3",
287 ] {
288 assert!(Decimal::new(good).is_ok(), "{good:?} should be accepted");
289 }
290 }
291}
292
293#[cfg(test)]
294mod oracle_tests {
295 //! Spec R13.3: the round-trip oracle compares `serde_json::Value`s, so it
296 //! can only see a decimal regression if `Value` equality is sensitive to
297 //! the lexeme. That sensitivity comes from `arbitrary_precision` being a
298 //! hard dependency feature (R2.2) — if it ever stops being one, these
299 //! tests fail here rather than letting the whole corpus suite go quietly
300 //! blind.
301
302 #[test]
303 fn value_equality_distinguishes_trailing_zeros() {
304 let two_sig: ::serde_json::Value = ::serde_json::from_str("0.50").expect("parse");
305 let one_sig: ::serde_json::Value = ::serde_json::from_str("0.5").expect("parse");
306 assert_ne!(
307 two_sig, one_sig,
308 "Value equality cannot see decimal precision; the round-trip \
309 oracle is blind and R13.3 is violated"
310 );
311 }
312
313 #[test]
314 fn value_round_trip_keeps_the_lexeme() {
315 for input in ["0.50", "1.000", "12345678901234567890.5"] {
316 let v: ::serde_json::Value = ::serde_json::from_str(input).expect("parse");
317 assert_eq!(::serde_json::to_string(&v).expect("serialize"), input);
318 }
319 }
320}
321
322#[cfg(test)]
323mod validate_tests {
324 use super::*;
325 use crate::validate::Validate;
326
327 #[test]
328 fn a_well_formed_decimal_validates() {
329 assert!(Decimal::new("0.50").expect("valid").is_valid());
330 assert!(Decimal::default().is_valid());
331 }
332
333 #[test]
334 fn a_decimal_that_slipped_past_the_constructor_is_reported() {
335 // Deserialization goes through `serde_json::Number`, whose grammar is
336 // JSON's rather than FHIR's. They agree today, so this guards the
337 // narrow case where they would not.
338 let via_serde: Decimal = ::serde_json::from_str("1e400").expect("json accepts it");
339 let issues = via_serde.validate();
340 assert!(
341 issues.is_empty() || issues[0].message.contains("FHIR decimal production"),
342 "unexpected issue: {issues:?}"
343 );
344 }
345}