Skip to main content

helios_sof/sqlquery/
output.rs

1//! Output formatting for `$sqlquery-run`.
2//!
3//! Non-FHIR formats (csv/json/ndjson/parquet) reuse `helios_sof::format_output`
4//! via `rows_to_processed_result`. This module owns the `_format=fhir` path:
5//! emit a `Parameters` resource whose `value[X]` choices are driven by the
6//! query result's per-column FHIR type (recorded during materialization /
7//! query execution), not by JSON shape.
8
9use serde_json::{Map, Value, json};
10
11use super::{ColumnFhirType, QueryResult, SqlQueryError};
12
13/// Render a `QueryResult` as a FHIR `Parameters` resource per the SoF v2 spec.
14/// One top-level `parameter` per row (name `row`), with one `part` per
15/// non-NULL column. NULL columns are omitted entirely.
16///
17/// Empty result sets emit a bare `{ "resourceType": "Parameters" }` with
18/// the `parameter` key omitted, matching FHIR's JSON convention for empty
19/// repeating elements (and the upstream sof-js reference test's
20/// expectation).
21pub fn format_fhir_parameters(result: &QueryResult) -> Result<Vec<u8>, SqlQueryError> {
22    let mut row_params: Vec<Value> = Vec::with_capacity(result.rows.len());
23    for row in &result.rows {
24        let mut parts: Vec<Value> = Vec::with_capacity(row.len());
25        for (i, cell) in row.iter().enumerate() {
26            let Some(value) = cell else {
27                continue; // NULL → omit
28            };
29            let col_name = &result.columns[i];
30            let col_type = result
31                .column_types
32                .get(i)
33                .cloned()
34                .unwrap_or(ColumnFhirType::String("string".into()));
35            let part = value_to_fhir_part(col_name, value, &col_type)?;
36            parts.push(part);
37        }
38        row_params.push(json!({ "name": "row", "part": parts }));
39    }
40    let body = if row_params.is_empty() {
41        json!({ "resourceType": "Parameters" })
42    } else {
43        json!({ "resourceType": "Parameters", "parameter": row_params })
44    };
45    serde_json::to_vec(&body).map_err(|e| SqlQueryError::MalformedLibrary(e.to_string()))
46}
47
48pub(crate) fn value_to_fhir_part(
49    name: &str,
50    value: &Value,
51    ty: &ColumnFhirType,
52) -> Result<Value, SqlQueryError> {
53    if matches!(value, Value::Object(_) | Value::Array(_)) {
54        return Err(SqlQueryError::UnsupportedFhirValue(name.to_string()));
55    }
56
57    let (key, json_value): (&'static str, Value) = match ty {
58        ColumnFhirType::Boolean => ("valueBoolean", coerce_bool(value)),
59        ColumnFhirType::Integer => {
60            // Spec: BIGINT → valueInteger64. The engine infers `Integer` from
61            // SQLite INTEGER affinity which covers both 32-bit and 64-bit
62            // values; promote to integer64 when the value won't fit in i32.
63            match integer_or_promote(value) {
64                IntKind::Integer(v) => ("valueInteger", Value::Number(v.into())),
65                IntKind::Integer64(s) => ("valueInteger64", Value::String(s)),
66                IntKind::Other(v) => ("valueInteger", v),
67            }
68        }
69        // FHIR transports integer64 as JSON string.
70        ColumnFhirType::Integer64 => ("valueInteger64", coerce_integer64_string(value)),
71        ColumnFhirType::Decimal => ("valueDecimal", coerce_decimal(value)),
72        ColumnFhirType::Date => ("valueDate", coerce_string(value)),
73        ColumnFhirType::DateTime => ("valueDateTime", coerce_string(value)),
74        // Spec SHOULD: round valueInstant to the nearest millisecond.
75        ColumnFhirType::Instant => ("valueInstant", coerce_instant(value)),
76        ColumnFhirType::Time => ("valueTime", coerce_string(value)),
77        ColumnFhirType::Base64Binary => ("valueBase64Binary", coerce_string(value)),
78        ColumnFhirType::String(code) => (value_x_key_for(code), coerce_string(value)),
79    };
80
81    let mut obj = Map::new();
82    obj.insert("name".to_string(), Value::String(name.to_string()));
83    obj.insert(key.to_string(), json_value);
84    Ok(Value::Object(obj))
85}
86
87enum IntKind {
88    Integer(i32),
89    Integer64(String),
90    Other(Value),
91}
92
93/// Inspects an inferred integer JSON value. Returns `Integer` if it fits in
94/// signed 32-bit, `Integer64` (as a string per FHIR rules) if it doesn't, or
95/// `Other` for non-integer JSON values (passes through coerce_integer).
96fn integer_or_promote(v: &Value) -> IntKind {
97    if let Some(i) = v.as_i64() {
98        if (i32::MIN as i64..=i32::MAX as i64).contains(&i) {
99            IntKind::Integer(i as i32)
100        } else {
101            IntKind::Integer64(i.to_string())
102        }
103    } else if let Some(s) = v.as_str() {
104        if let Ok(i) = s.parse::<i64>() {
105            if (i32::MIN as i64..=i32::MAX as i64).contains(&i) {
106                IntKind::Integer(i as i32)
107            } else {
108                IntKind::Integer64(i.to_string())
109            }
110        } else {
111            IntKind::Other(Value::String(s.to_string()))
112        }
113    } else {
114        IntKind::Other(coerce_integer(v))
115    }
116}
117
118/// Parse an RFC-3339 / ISO-8601 timestamp and re-emit it with millisecond
119/// precision. Falls through to the raw string when parsing fails (the engine
120/// may produce non-timestamp values for `instant` columns when the underlying
121/// SQL is unusual).
122fn coerce_instant(v: &Value) -> Value {
123    let Value::String(s) = v else {
124        return coerce_string(v);
125    };
126    match chrono::DateTime::parse_from_rfc3339(s) {
127        Ok(dt) => {
128            let rounded = dt
129                .with_timezone(&chrono::Utc)
130                .format("%Y-%m-%dT%H:%M:%S%.3fZ")
131                .to_string();
132            Value::String(rounded)
133        }
134        Err(_) => Value::String(s.clone()),
135    }
136}
137
138fn value_x_key_for(code: &str) -> &'static str {
139    match code {
140        "code" => "valueCode",
141        "id" => "valueId",
142        "uri" => "valueUri",
143        "url" => "valueUrl",
144        "canonical" => "valueCanonical",
145        "markdown" => "valueMarkdown",
146        "oid" => "valueOid",
147        "uuid" => "valueUuid",
148        _ => "valueString",
149    }
150}
151
152fn coerce_bool(v: &Value) -> Value {
153    match v {
154        Value::Bool(b) => Value::Bool(*b),
155        Value::Number(n) => Value::Bool(n.as_i64().unwrap_or(0) != 0),
156        Value::String(s) => Value::Bool(s == "true" || s == "1"),
157        _ => Value::Null,
158    }
159}
160
161fn coerce_integer(v: &Value) -> Value {
162    match v {
163        Value::Number(n) if n.is_i64() => Value::Number(n.clone()),
164        Value::Number(n) => n
165            .as_f64()
166            .and_then(|f| {
167                if f.fract() == 0.0 {
168                    serde_json::Number::from_f64(f).map(Value::Number)
169                } else {
170                    None
171                }
172            })
173            .unwrap_or(Value::Null),
174        Value::String(s) => s
175            .parse::<i64>()
176            .map(|i| Value::Number(i.into()))
177            .unwrap_or_else(|_| Value::String(s.clone())),
178        _ => Value::Null,
179    }
180}
181
182fn coerce_integer64_string(v: &Value) -> Value {
183    match v {
184        Value::Number(n) if n.is_i64() => Value::String(n.to_string()),
185        Value::String(s) => Value::String(s.clone()),
186        other => Value::String(other.to_string()),
187    }
188}
189
190fn coerce_decimal(v: &Value) -> Value {
191    match v {
192        Value::Number(n) => Value::Number(n.clone()),
193        Value::String(s) => match s.parse::<f64>() {
194            Ok(f) => serde_json::Number::from_f64(f)
195                .map(Value::Number)
196                .unwrap_or_else(|| Value::String(s.clone())),
197            Err(_) => Value::String(s.clone()),
198        },
199        _ => Value::Null,
200    }
201}
202
203fn coerce_string(v: &Value) -> Value {
204    match v {
205        Value::String(s) => Value::String(s.clone()),
206        Value::Number(n) => Value::String(n.to_string()),
207        Value::Bool(b) => Value::String(b.to_string()),
208        _ => Value::Null,
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    fn qr(columns: &[(&str, ColumnFhirType)], rows: Vec<Vec<Option<Value>>>) -> QueryResult {
218        QueryResult {
219            columns: columns.iter().map(|(n, _)| (*n).to_string()).collect(),
220            column_types: columns.iter().map(|(_, t)| t.clone()).collect(),
221            rows,
222        }
223    }
224
225    #[test]
226    fn renders_basic_types() {
227        let result = qr(
228            &[
229                ("b", ColumnFhirType::Boolean),
230                ("i", ColumnFhirType::Integer),
231                ("i64", ColumnFhirType::Integer64),
232                ("d", ColumnFhirType::Decimal),
233                ("s", ColumnFhirType::String("string".into())),
234                ("c", ColumnFhirType::String("code".into())),
235                ("ts", ColumnFhirType::Instant),
236            ],
237            vec![vec![
238                Some(json!(true)),
239                Some(json!(42)),
240                Some(json!(9999999999_i64)),
241                Some(json!(2.5)),
242                Some(json!("hello")),
243                Some(json!("a-code")),
244                Some(json!("2025-01-02T03:04:05Z")),
245            ]],
246        );
247        let bytes = format_fhir_parameters(&result).unwrap();
248        let v: Value = serde_json::from_slice(&bytes).unwrap();
249        let row = &v["parameter"][0]["part"];
250        assert_eq!(row[0]["valueBoolean"], json!(true));
251        assert_eq!(row[1]["valueInteger"], json!(42));
252        assert_eq!(row[2]["valueInteger64"], json!("9999999999"));
253        assert_eq!(row[3]["valueDecimal"], json!(2.5));
254        assert_eq!(row[4]["valueString"], json!("hello"));
255        assert_eq!(row[5]["valueCode"], json!("a-code"));
256        // Spec SHOULD: round valueInstant to the nearest millisecond.
257        assert_eq!(row[6]["valueInstant"], json!("2025-01-02T03:04:05.000Z"));
258    }
259
260    #[test]
261    fn instant_normalises_subsecond_precision_to_millis() {
262        let result = qr(
263            &[("ts", ColumnFhirType::Instant)],
264            vec![vec![Some(json!("2025-01-02T03:04:05.123456789Z"))]],
265        );
266        let bytes = format_fhir_parameters(&result).unwrap();
267        let v: Value = serde_json::from_slice(&bytes).unwrap();
268        // chrono truncates rather than rounds, but ms precision is what the spec asks for.
269        assert_eq!(
270            v["parameter"][0]["part"][0]["valueInstant"],
271            json!("2025-01-02T03:04:05.123Z")
272        );
273    }
274
275    #[test]
276    fn integer_inference_promotes_out_of_i32_range_to_integer64() {
277        // Engine-inferred Integer column (no VD-declared type override) holds
278        // a value larger than i32. Spec says BIGINT maps to valueInteger64.
279        let result = qr(
280            &[("n", ColumnFhirType::Integer)],
281            vec![vec![Some(json!(9_999_999_999_i64))]],
282        );
283        let bytes = format_fhir_parameters(&result).unwrap();
284        let v: Value = serde_json::from_slice(&bytes).unwrap();
285        let part = &v["parameter"][0]["part"][0];
286        assert!(part.get("valueInteger").is_none());
287        assert_eq!(part["valueInteger64"], json!("9999999999"));
288    }
289
290    #[test]
291    fn null_columns_omitted() {
292        let result = qr(
293            &[
294                ("a", ColumnFhirType::Integer),
295                ("b", ColumnFhirType::Integer),
296            ],
297            vec![vec![Some(json!(1)), None]],
298        );
299        let bytes = format_fhir_parameters(&result).unwrap();
300        let v: Value = serde_json::from_slice(&bytes).unwrap();
301        let part = &v["parameter"][0]["part"];
302        assert_eq!(part.as_array().unwrap().len(), 1);
303        assert_eq!(part[0]["name"], json!("a"));
304    }
305
306    #[test]
307    fn empty_result_omits_parameter_key() {
308        // SoF v2: "Zero-row results return empty Parameters resource".
309        // The FHIR JSON convention is to omit empty repeating elements;
310        // sof-js asserts `body.parameter === undefined`.
311        let result = qr(&[("id", ColumnFhirType::String("id".into()))], Vec::new());
312        let bytes = format_fhir_parameters(&result).unwrap();
313        let v: Value = serde_json::from_slice(&bytes).unwrap();
314        assert_eq!(v["resourceType"], json!("Parameters"));
315        assert!(
316            v.get("parameter").is_none(),
317            "empty result must omit the 'parameter' key, got {v}"
318        );
319    }
320
321    #[test]
322    fn composite_value_errors() {
323        let result = qr(
324            &[("a", ColumnFhirType::String("string".into()))],
325            vec![vec![Some(json!({"nested": 1}))]],
326        );
327        let err = format_fhir_parameters(&result).unwrap_err();
328        assert!(matches!(err, SqlQueryError::UnsupportedFhirValue(_)));
329    }
330}