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