Skip to main content

helios_sof/
constants.rs

1//! Shared SoF v2 `ViewDefinition.constant[]` parsing.
2//!
3//! Both the in-process FHIRPath evaluator (this crate's
4//! [`crate::run_view_definition`]) and the in-DB SQL compiler in
5//! `helios-persistence` walk `ViewDefinition.constant[]` and interpret the same
6//! `value[X]` field family per the SoF v2 spec. This module is the single
7//! source of truth for that field list and primitive recognition, so a new
8//! primitive only needs to be added in one place.
9//!
10//! Engines convert from [`ConstantValue`] into their own value types:
11//! - `helios-sof` builds an [`EvaluationResult`] via
12//!   [`ConstantValue::to_evaluation_result`] for the in-process FHIRPath
13//!   evaluator. The four per-version `ViewDefinitionConstantTrait` impls in
14//!   [`crate::traits`] map their typed `ViewDefinitionConstantValue` variants
15//!   into [`ConstantValue`] and call this method.
16//! - `helios-persistence` walks `serde_json::Value` and calls
17//!   [`parse_constant_from_json`], then lifts to its `LitValue`.
18
19use helios_fhirpath_support::{EvaluationResult, TypeInfoResult};
20use serde_json::Value;
21
22use crate::SofError;
23
24/// Neutral SoF constant value covering every `value[X]` primitive family
25/// the SoF v2 spec allows for `ViewDefinition.constant[]`.
26///
27/// Stringly typed for the date/time/decimal families so engines can preserve
28/// the lexical form (decimal precision; pre-prefixed `@`/`@T` literals).
29#[derive(Debug, Clone, PartialEq)]
30pub enum ConstantValue {
31    /// `valueString`.
32    String(String),
33    /// `valueCode` — bound as text in FHIRPath/SQL.
34    Code(String),
35    /// `valueId`, `valueUri`, `valueUrl`, `valueOid`, `valueUuid`,
36    /// `valueCanonical` — all bind as text.
37    Identifier(String),
38    /// `valueBase64Binary`.
39    Base64Binary(String),
40    /// `valueMarkdown` (currently only surfaced from the JSON path; no typed
41    /// variant exists in any FHIR version's ViewDefinitionConstantValue yet).
42    Markdown(String),
43    /// `valueBoolean`.
44    Boolean(bool),
45    /// `valueInteger`.
46    Integer(i64),
47    /// `valuePositiveInt` (FHIR 1..*) — surfaces as Integer in FHIRPath.
48    PositiveInt(i64),
49    /// `valueUnsignedInt` (FHIR 0..*) — surfaces as Integer in FHIRPath.
50    UnsignedInt(i64),
51    /// `valueInteger64` (R5+). Surfaces as Integer64 in FHIRPath.
52    Integer64(i64),
53    /// `valueDecimal` — kept as its lexical form so precision survives the
54    /// trip through FHIRPath / SQL parameter binding.
55    Decimal(String),
56    /// `valueDate`.
57    Date(String),
58    /// `valueDateTime` — may or may not be `@`-prefixed; normalised in
59    /// [`Self::to_evaluation_result`].
60    DateTime(String),
61    /// `valueTime` — may or may not be `@T`-prefixed; normalised in
62    /// [`Self::to_evaluation_result`].
63    Time(String),
64    /// `valueInstant` — surfaces as `EvaluationResult::DateTime` tagged with
65    /// FHIR `instant`.
66    Instant(String),
67}
68
69impl ConstantValue {
70    /// Renders this constant as an [`EvaluationResult`] for the in-process
71    /// FHIRPath evaluator. Handles `@` / `@T` literal prefixing and decimal
72    /// precision parsing. Returns `Err` only when a [`Self::Decimal`] lexical
73    /// form fails to parse.
74    pub fn to_evaluation_result(&self) -> Result<EvaluationResult, SofError> {
75        Ok(match self {
76            ConstantValue::String(s)
77            | ConstantValue::Code(s)
78            | ConstantValue::Identifier(s)
79            | ConstantValue::Base64Binary(s)
80            | ConstantValue::Markdown(s) => EvaluationResult::String(s.clone(), None, None),
81            ConstantValue::Boolean(b) => EvaluationResult::Boolean(*b, None, None),
82            ConstantValue::Integer(i)
83            | ConstantValue::PositiveInt(i)
84            | ConstantValue::UnsignedInt(i) => EvaluationResult::Integer(*i, None, None),
85            ConstantValue::Integer64(i) => EvaluationResult::Integer64(*i, None, None),
86            ConstantValue::Decimal(s) => {
87                let parsed = s.parse().map_err(|_| {
88                    SofError::InvalidViewDefinition(format!("Invalid decimal value '{s}'"))
89                })?;
90                EvaluationResult::Decimal(parsed, None, None)
91            }
92            ConstantValue::Date(s) => EvaluationResult::Date(s.clone(), None, None),
93            ConstantValue::DateTime(s) => EvaluationResult::DateTime(
94                prefix_at(s),
95                Some(TypeInfoResult::new("FHIR", "dateTime")),
96                None,
97            ),
98            ConstantValue::Time(s) => EvaluationResult::Time(prefix_at_t(s), None, None),
99            ConstantValue::Instant(s) => EvaluationResult::DateTime(
100                prefix_at(s),
101                Some(TypeInfoResult::new("FHIR", "instant")),
102                None,
103            ),
104        })
105    }
106}
107
108fn prefix_at(s: &str) -> String {
109    if s.starts_with('@') {
110        s.to_string()
111    } else {
112        format!("@{s}")
113    }
114}
115
116fn prefix_at_t(s: &str) -> String {
117    if s.starts_with("@T") {
118        s.to_string()
119    } else {
120        format!("@T{s}")
121    }
122}
123
124/// Parses a raw JSON `ViewDefinition.constant[]` entry into `(name, value)`.
125///
126/// Used by the in-DB compiler which walks the ViewDefinition as
127/// `serde_json::Value`. The in-process evaluator walks typed FHIR structs
128/// instead and converts through the per-version trait impls.
129///
130/// Errors when `name` is missing or no recognised `value[X]` field is present.
131pub fn parse_constant_from_json(c: &Value) -> Result<(String, ConstantValue), SofError> {
132    let name = c
133        .get("name")
134        .and_then(|v| v.as_str())
135        .ok_or_else(|| {
136            SofError::InvalidViewDefinition("ViewDefinition.constant.name is required".to_string())
137        })?
138        .to_string();
139    let value = read_constant_value(c).ok_or_else(|| {
140        SofError::InvalidViewDefinition(format!(
141            "ViewDefinition.constant '{name}' must have exactly one supported value[X] field"
142        ))
143    })?;
144    Ok((name, value))
145}
146
147fn read_constant_value(c: &Value) -> Option<ConstantValue> {
148    if let Some(s) = c.get("valueString").and_then(|v| v.as_str()) {
149        return Some(ConstantValue::String(s.to_string()));
150    }
151    if let Some(b) = c.get("valueBoolean").and_then(|v| v.as_bool()) {
152        return Some(ConstantValue::Boolean(b));
153    }
154    if let Some(n) = c.get("valueInteger").and_then(|v| v.as_i64()) {
155        return Some(ConstantValue::Integer(n));
156    }
157    if let Some(n) = c.get("valueInteger64").and_then(|v| v.as_i64()) {
158        return Some(ConstantValue::Integer64(n));
159    }
160    if let Some(n) = c.get("valuePositiveInt").and_then(|v| v.as_i64()) {
161        return Some(ConstantValue::PositiveInt(n));
162    }
163    if let Some(n) = c.get("valueUnsignedInt").and_then(|v| v.as_i64()) {
164        return Some(ConstantValue::UnsignedInt(n));
165    }
166    if let Some(n) = c.get("valueDecimal") {
167        // Preserve precision by going through the JSON string form.
168        return Some(ConstantValue::Decimal(n.to_string()));
169    }
170    if let Some(s) = c.get("valueCode").and_then(|v| v.as_str()) {
171        return Some(ConstantValue::Code(s.to_string()));
172    }
173    if let Some(s) = c.get("valueBase64Binary").and_then(|v| v.as_str()) {
174        return Some(ConstantValue::Base64Binary(s.to_string()));
175    }
176    if let Some(s) = c.get("valueMarkdown").and_then(|v| v.as_str()) {
177        return Some(ConstantValue::Markdown(s.to_string()));
178    }
179    for key in [
180        "valueId",
181        "valueUri",
182        "valueUrl",
183        "valueOid",
184        "valueUuid",
185        "valueCanonical",
186    ] {
187        if let Some(s) = c.get(key).and_then(|v| v.as_str()) {
188            return Some(ConstantValue::Identifier(s.to_string()));
189        }
190    }
191    if let Some(s) = c.get("valueDate").and_then(|v| v.as_str()) {
192        return Some(ConstantValue::Date(s.to_string()));
193    }
194    if let Some(s) = c.get("valueDateTime").and_then(|v| v.as_str()) {
195        return Some(ConstantValue::DateTime(s.to_string()));
196    }
197    if let Some(s) = c.get("valueTime").and_then(|v| v.as_str()) {
198        return Some(ConstantValue::Time(s.to_string()));
199    }
200    if let Some(s) = c.get("valueInstant").and_then(|v| v.as_str()) {
201        return Some(ConstantValue::Instant(s.to_string()));
202    }
203    None
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use serde_json::json;
210
211    fn parse(v: serde_json::Value) -> ConstantValue {
212        parse_constant_from_json(&v).expect("parse").1
213    }
214
215    #[test]
216    fn each_value_field_lowers_to_matching_variant() {
217        let cases: &[(serde_json::Value, ConstantValue)] = &[
218            (
219                json!({"name": "x", "valueString": "hello"}),
220                ConstantValue::String("hello".to_string()),
221            ),
222            (
223                json!({"name": "x", "valueBoolean": true}),
224                ConstantValue::Boolean(true),
225            ),
226            (
227                json!({"name": "x", "valueInteger": 7}),
228                ConstantValue::Integer(7),
229            ),
230            (
231                json!({"name": "x", "valueInteger64": 9_000_000_000i64}),
232                ConstantValue::Integer64(9_000_000_000),
233            ),
234            (
235                json!({"name": "x", "valuePositiveInt": 2}),
236                ConstantValue::PositiveInt(2),
237            ),
238            (
239                json!({"name": "x", "valueUnsignedInt": 0}),
240                ConstantValue::UnsignedInt(0),
241            ),
242            (
243                json!({"name": "x", "valueDecimal": 1.25}),
244                ConstantValue::Decimal("1.25".to_string()),
245            ),
246            (
247                json!({"name": "x", "valueCode": "active"}),
248                ConstantValue::Code("active".to_string()),
249            ),
250            (
251                json!({"name": "x", "valueBase64Binary": "QUJD"}),
252                ConstantValue::Base64Binary("QUJD".to_string()),
253            ),
254            (
255                json!({"name": "x", "valueMarkdown": "# h"}),
256                ConstantValue::Markdown("# h".to_string()),
257            ),
258            (
259                json!({"name": "x", "valueId": "abc-123"}),
260                ConstantValue::Identifier("abc-123".to_string()),
261            ),
262            (
263                json!({"name": "x", "valueUri": "http://example.org/"}),
264                ConstantValue::Identifier("http://example.org/".to_string()),
265            ),
266            (
267                json!({"name": "x", "valueUrl": "http://example.org/r"}),
268                ConstantValue::Identifier("http://example.org/r".to_string()),
269            ),
270            (
271                json!({"name": "x", "valueOid": "urn:oid:1.2.3"}),
272                ConstantValue::Identifier("urn:oid:1.2.3".to_string()),
273            ),
274            (
275                json!({"name": "x", "valueUuid": "urn:uuid:00000000-0000-0000-0000-000000000000"}),
276                ConstantValue::Identifier(
277                    "urn:uuid:00000000-0000-0000-0000-000000000000".to_string(),
278                ),
279            ),
280            (
281                json!({"name": "x", "valueCanonical": "http://x|1"}),
282                ConstantValue::Identifier("http://x|1".to_string()),
283            ),
284            (
285                json!({"name": "x", "valueDate": "2024-01-02"}),
286                ConstantValue::Date("2024-01-02".to_string()),
287            ),
288            (
289                json!({"name": "x", "valueDateTime": "2024-01-02T03:04:05Z"}),
290                ConstantValue::DateTime("2024-01-02T03:04:05Z".to_string()),
291            ),
292            (
293                json!({"name": "x", "valueTime": "03:04:05"}),
294                ConstantValue::Time("03:04:05".to_string()),
295            ),
296            (
297                json!({"name": "x", "valueInstant": "2024-01-02T03:04:05Z"}),
298                ConstantValue::Instant("2024-01-02T03:04:05Z".to_string()),
299            ),
300        ];
301        for (input, expected) in cases {
302            assert_eq!(&parse(input.clone()), expected, "input={input}");
303        }
304    }
305
306    #[test]
307    fn missing_name_errors() {
308        let err = parse_constant_from_json(&json!({"valueString": "x"})).unwrap_err();
309        assert!(matches!(err, SofError::InvalidViewDefinition(_)));
310    }
311
312    #[test]
313    fn unknown_value_field_errors() {
314        let err = parse_constant_from_json(&json!({"name": "x", "valueWhatever": 1})).unwrap_err();
315        assert!(matches!(err, SofError::InvalidViewDefinition(_)));
316    }
317
318    #[test]
319    fn datetime_prefixing_idempotent() {
320        let cv = ConstantValue::DateTime("2024-01-02T03:04:05Z".to_string());
321        match cv.to_evaluation_result().unwrap() {
322            EvaluationResult::DateTime(s, _, _) => assert_eq!(s, "@2024-01-02T03:04:05Z"),
323            other => panic!("unexpected: {other:?}"),
324        }
325        let cv = ConstantValue::DateTime("@2024-01-02T03:04:05Z".to_string());
326        match cv.to_evaluation_result().unwrap() {
327            EvaluationResult::DateTime(s, _, _) => assert_eq!(s, "@2024-01-02T03:04:05Z"),
328            other => panic!("unexpected: {other:?}"),
329        }
330    }
331
332    #[test]
333    fn time_prefixing_idempotent() {
334        let cv = ConstantValue::Time("03:04:05".to_string());
335        match cv.to_evaluation_result().unwrap() {
336            EvaluationResult::Time(s, _, _) => assert_eq!(s, "@T03:04:05"),
337            other => panic!("unexpected: {other:?}"),
338        }
339        let cv = ConstantValue::Time("@T03:04:05".to_string());
340        match cv.to_evaluation_result().unwrap() {
341            EvaluationResult::Time(s, _, _) => assert_eq!(s, "@T03:04:05"),
342            other => panic!("unexpected: {other:?}"),
343        }
344    }
345
346    #[test]
347    fn bad_decimal_errors() {
348        let cv = ConstantValue::Decimal("not-a-number".to_string());
349        assert!(matches!(
350            cv.to_evaluation_result(),
351            Err(SofError::InvalidViewDefinition(_))
352        ));
353    }
354}