Skip to main content

helios_sof/sqlquery/
bind.rs

1//! Bind values from a supplied `Parameters` resource to `Library.parameter`
2//! declarations, using FHIR type codes to choose the right rusqlite value.
3
4use rusqlite::types::Value as SqlValue;
5use serde_json::Value;
6
7use super::{LibraryParameter, SqlQueryError};
8
9/// FHIR type codes this binder knows how to map to a
10/// `Parameters.parameter.value[x]` — the source of truth for what a
11/// `Library.parameter[].type` value this module can actually bind, and the
12/// list a caller building a type picker (#841's "Add parameter") should
13/// offer so it never diverges from what running the query actually
14/// supports.
15///
16/// Ordered for display, not alphabetically: the seven types issue #841
17/// names first (`string`, `integer`, `decimal`, `boolean`, `date`,
18/// `dateTime`, `code`), then every other type code [`value_x_suffix_for`]
19/// and [`expected_value_keys_for`] already handle.
20pub const BINDABLE_PARAMETER_TYPES: &[&str] = &[
21    "string",
22    "integer",
23    "decimal",
24    "boolean",
25    "date",
26    "dateTime",
27    "code",
28    "integer64",
29    "positiveInt",
30    "unsignedInt",
31    "instant",
32    "time",
33    "uri",
34    "url",
35    "canonical",
36    "id",
37    "markdown",
38    "oid",
39    "uuid",
40];
41
42/// A named, type-checked binding. The handler passes a `Vec<BoundParam>` to
43/// the engine; `name` is the `Library.parameter.name` without a leading colon.
44#[derive(Debug, Clone)]
45pub struct BoundParam {
46    pub name: String,
47    pub value: SqlValue,
48}
49
50/// Walks the supplied `parameters` Parameters resource and produces bindings
51/// for every `Library.parameter[use="in"]`. Missing values fall back to the
52/// declared default; if no default, returns `BindParameter`.
53pub fn bind_supplied_params(
54    declared: &[LibraryParameter],
55    supplied: Option<&Value>,
56) -> Result<Vec<BoundParam>, SqlQueryError> {
57    let supplied_entries: Vec<&Value> = supplied
58        .and_then(|v| v.get("parameter"))
59        .and_then(|p| p.as_array())
60        .map(|arr| arr.iter().collect())
61        .unwrap_or_default();
62
63    // Reject unknown supplied names so callers learn about typos.
64    let declared_names: std::collections::HashSet<&str> =
65        declared.iter().map(|d| d.name.as_str()).collect();
66    for entry in &supplied_entries {
67        if let Some(name) = entry.get("name").and_then(|n| n.as_str()) {
68            if !declared_names.contains(name) {
69                return Err(SqlQueryError::BindParameter(format!(
70                    "supplied parameter '{name}' is not declared in Library.parameter"
71                )));
72            }
73        }
74    }
75
76    let mut out = Vec::with_capacity(declared.len());
77    for p in declared {
78        let supplied_entry = supplied_entries
79            .iter()
80            .find(|e| e.get("name").and_then(|n| n.as_str()) == Some(p.name.as_str()));
81        let value = if let Some(entry) = supplied_entry {
82            value_for_param(p, entry)?
83        } else if let Some(default) = &p.default_value {
84            // Default values are FHIR `value[X]` shapes; wrap them in a fake
85            // parameter entry to reuse the binder.
86            let fake = serde_json::json!({ "name": p.name, "value": default });
87            // Default extension shape uses `defaultX`; here we already have
88            // the raw value, so synthesize the right key from the type code.
89            let key = format!(
90                "value{}",
91                first_letter_upper(value_x_suffix_for(&p.type_code))
92            );
93            let mut obj = serde_json::Map::new();
94            obj.insert("name".to_string(), Value::String(p.name.clone()));
95            obj.insert(key, default.clone());
96            let _ = fake; // suppress unused warning when default-value mode
97            value_for_param(p, &Value::Object(obj))?
98        } else {
99            return Err(SqlQueryError::BindParameter(format!(
100                "parameter '{}' has no supplied value and no default",
101                p.name
102            )));
103        };
104        out.push(BoundParam {
105            name: p.name.clone(),
106            value,
107        });
108    }
109    Ok(out)
110}
111
112fn value_for_param(p: &LibraryParameter, entry: &Value) -> Result<SqlValue, SqlQueryError> {
113    let obj = entry
114        .as_object()
115        .ok_or_else(|| SqlQueryError::BindParameter("parameter entry must be an object".into()))?;
116
117    let suffix = value_x_suffix_for(&p.type_code);
118    let expected_keys = expected_value_keys_for(&p.type_code);
119    let value = obj
120        .iter()
121        .find(|(k, _)| {
122            k.starts_with("value")
123                && (expected_keys.contains(&k.as_str()) || k == &&format!("value{suffix}"))
124        })
125        .map(|(_, v)| v);
126
127    let value = match value {
128        Some(v) => v,
129        None => {
130            return Err(SqlQueryError::BindParameter(format!(
131                "parameter '{}' (type {}) is missing a value{suffix} entry",
132                p.name, p.type_code
133            )));
134        }
135    };
136
137    bind_value(&p.name, &p.type_code, value)
138}
139
140fn first_letter_upper(s: &str) -> String {
141    let mut chars = s.chars();
142    match chars.next() {
143        Some(c) => c.to_uppercase().chain(chars).collect(),
144        None => String::new(),
145    }
146}
147
148fn value_x_suffix_for(type_code: &str) -> &'static str {
149    match type_code {
150        "boolean" => "Boolean",
151        "integer" | "positiveInt" | "unsignedInt" => "Integer",
152        "integer64" => "Integer64",
153        "decimal" => "Decimal",
154        "date" => "Date",
155        "dateTime" => "DateTime",
156        "instant" => "Instant",
157        "time" => "Time",
158        "string" => "String",
159        "code" => "Code",
160        "id" => "Id",
161        "uri" => "Uri",
162        "url" => "Url",
163        "canonical" => "Canonical",
164        "markdown" => "Markdown",
165        "oid" => "Oid",
166        "uuid" => "Uuid",
167        "base64Binary" => "Base64Binary",
168        _ => "String",
169    }
170}
171
172fn expected_value_keys_for(type_code: &str) -> &'static [&'static str] {
173    match type_code {
174        "boolean" => &["valueBoolean"],
175        "integer" | "positiveInt" | "unsignedInt" => {
176            &["valueInteger", "valuePositiveInt", "valueUnsignedInt"]
177        }
178        "integer64" => &["valueInteger64"],
179        "decimal" => &["valueDecimal"],
180        "date" => &["valueDate"],
181        "dateTime" => &["valueDateTime"],
182        "instant" => &["valueInstant"],
183        "time" => &["valueTime"],
184        "string" | "code" | "id" | "uri" | "url" | "canonical" | "markdown" | "oid" | "uuid" => &[
185            "valueString",
186            "valueCode",
187            "valueId",
188            "valueUri",
189            "valueUrl",
190            "valueCanonical",
191            "valueMarkdown",
192            "valueOid",
193            "valueUuid",
194        ],
195        "base64Binary" => &["valueBase64Binary"],
196        _ => &["valueString"],
197    }
198}
199
200fn bind_value(name: &str, type_code: &str, v: &Value) -> Result<SqlValue, SqlQueryError> {
201    let invalid = |reason: String| SqlQueryError::BindParameter(format!("'{name}': {reason}"));
202    match type_code {
203        "boolean" => v
204            .as_bool()
205            .map(|b| SqlValue::Integer(if b { 1 } else { 0 }))
206            .ok_or_else(|| invalid("expected JSON boolean".into())),
207        "integer" | "positiveInt" | "unsignedInt" => v
208            .as_i64()
209            .map(SqlValue::Integer)
210            .ok_or_else(|| invalid("expected JSON integer".into())),
211        "integer64" => {
212            // FHIR transports integer64 as a JSON string (per the FHIR spec) but
213            // many clients send a number. Accept either.
214            if let Some(i) = v.as_i64() {
215                return Ok(SqlValue::Integer(i));
216            }
217            if let Some(s) = v.as_str() {
218                return s
219                    .parse::<i64>()
220                    .map(SqlValue::Integer)
221                    .map_err(|e| invalid(format!("integer64 parse: {e}")));
222            }
223            Err(invalid("expected JSON integer or numeric string".into()))
224        }
225        "decimal" => v
226            .as_f64()
227            .map(SqlValue::Real)
228            .or_else(|| v.as_i64().map(|i| SqlValue::Real(i as f64)))
229            .ok_or_else(|| invalid("expected JSON number".into())),
230        "date" => {
231            let s = v
232                .as_str()
233                .ok_or_else(|| invalid("expected JSON string".into()))?;
234            chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
235                .map_err(|e| invalid(format!("invalid date '{s}': {e}")))?;
236            Ok(SqlValue::Text(s.to_string()))
237        }
238        "dateTime" | "instant" => {
239            let s = v
240                .as_str()
241                .ok_or_else(|| invalid("expected JSON string".into()))?;
242            chrono::DateTime::parse_from_rfc3339(s)
243                .map_err(|e| invalid(format!("invalid {type_code} '{s}': {e}")))?;
244            Ok(SqlValue::Text(s.to_string()))
245        }
246        "time" => {
247            let s = v
248                .as_str()
249                .ok_or_else(|| invalid("expected JSON string".into()))?;
250            chrono::NaiveTime::parse_from_str(s, "%H:%M:%S")
251                .or_else(|_| chrono::NaiveTime::parse_from_str(s, "%H:%M:%S%.f"))
252                .map_err(|e| invalid(format!("invalid time '{s}': {e}")))?;
253            Ok(SqlValue::Text(s.to_string()))
254        }
255        // String-ish FHIR types.
256        _ => v
257            .as_str()
258            .map(|s| SqlValue::Text(s.to_string()))
259            .ok_or_else(|| invalid("expected JSON string".into())),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use serde_json::json;
267
268    fn decl(name: &str, type_code: &str) -> LibraryParameter {
269        LibraryParameter {
270            name: name.into(),
271            type_code: type_code.into(),
272            has_default: false,
273            default_value: None,
274        }
275    }
276
277    #[test]
278    fn binds_integer_and_string() {
279        let declared = vec![decl("min", "integer"), decl("city", "string")];
280        let supplied = json!({
281            "resourceType": "Parameters",
282            "parameter": [
283                {"name": "min", "valueInteger": 18},
284                {"name": "city", "valueString": "NYC"}
285            ]
286        });
287        let out = bind_supplied_params(&declared, Some(&supplied)).unwrap();
288        assert_eq!(out.len(), 2);
289        assert!(matches!(out[0].value, SqlValue::Integer(18)));
290        assert!(matches!(&out[1].value, SqlValue::Text(s) if s == "NYC"));
291    }
292
293    #[test]
294    fn missing_required_param_errors() {
295        let declared = vec![decl("min", "integer")];
296        let supplied = json!({"resourceType": "Parameters", "parameter": []});
297        let err = bind_supplied_params(&declared, Some(&supplied)).unwrap_err();
298        assert!(matches!(err, SqlQueryError::BindParameter(_)));
299    }
300
301    #[test]
302    fn unknown_supplied_param_errors() {
303        let declared = vec![decl("min", "integer")];
304        let supplied = json!({
305            "resourceType": "Parameters",
306            "parameter": [
307                {"name": "min", "valueInteger": 1},
308                {"name": "unknown", "valueString": "x"}
309            ]
310        });
311        let err = bind_supplied_params(&declared, Some(&supplied)).unwrap_err();
312        assert!(matches!(err, SqlQueryError::BindParameter(_)));
313    }
314
315    #[test]
316    fn type_mismatch_errors() {
317        let declared = vec![decl("min", "integer")];
318        let supplied = json!({
319            "resourceType": "Parameters",
320            "parameter": [{"name": "min", "valueString": "oops"}]
321        });
322        let err = bind_supplied_params(&declared, Some(&supplied)).unwrap_err();
323        assert!(matches!(err, SqlQueryError::BindParameter(_)));
324    }
325
326    #[test]
327    fn datetime_validates() {
328        let declared = vec![decl("ts", "dateTime")];
329        let supplied = json!({
330            "resourceType": "Parameters",
331            "parameter": [{"name": "ts", "valueDateTime": "not-a-date"}]
332        });
333        assert!(bind_supplied_params(&declared, Some(&supplied)).is_err());
334
335        let ok = json!({
336            "resourceType": "Parameters",
337            "parameter": [{"name": "ts", "valueDateTime": "2025-01-02T03:04:05Z"}]
338        });
339        assert!(bind_supplied_params(&declared, Some(&ok)).is_ok());
340    }
341
342    /// #841: every code in [`BINDABLE_PARAMETER_TYPES`] must actually
343    /// bind a well-typed sample value — guards against the list drifting
344    /// from `value_x_suffix_for`/`bind_value`'s own match arms (a code that
345    /// silently fell through to the string-ish fallback would still "bind",
346    /// just not the way its FHIR type promises).
347    #[test]
348    fn every_bindable_parameter_type_resolves_in_the_binder() {
349        fn sample_value(type_code: &str) -> Value {
350            match type_code {
351                "boolean" => json!(true),
352                "integer" | "positiveInt" | "unsignedInt" | "integer64" => json!(1),
353                "decimal" => json!(1.5),
354                "date" => json!("2025-01-02"),
355                "dateTime" | "instant" => json!("2025-01-02T03:04:05Z"),
356                "time" => json!("03:04:05"),
357                _ => json!("sample"),
358            }
359        }
360
361        for &type_code in BINDABLE_PARAMETER_TYPES {
362            let declared = vec![decl("p", type_code)];
363            let key = format!("value{}", value_x_suffix_for(type_code));
364            let mut entry = serde_json::Map::new();
365            entry.insert("name".to_string(), json!("p"));
366            entry.insert(key, sample_value(type_code));
367            let supplied = json!({
368                "resourceType": "Parameters",
369                "parameter": [Value::Object(entry)],
370            });
371            let result = bind_supplied_params(&declared, Some(&supplied));
372            assert!(
373                result.is_ok(),
374                "type code '{type_code}' failed to bind a sample value: {:?}",
375                result.err()
376            );
377        }
378    }
379
380    #[test]
381    fn injection_payload_bound_as_text() {
382        let declared = vec![decl("name", "string")];
383        let supplied = json!({
384            "resourceType": "Parameters",
385            "parameter": [{"name": "name", "valueString": "Robert');--"}]
386        });
387        let out = bind_supplied_params(&declared, Some(&supplied)).unwrap();
388        match &out[0].value {
389            SqlValue::Text(s) => assert_eq!(s, "Robert');--"),
390            _ => panic!("expected Text"),
391        }
392    }
393}