Skip to main content

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}