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