Skip to main content

helios_sof/sqlquery/
library.rs

1//! Parse a SQLQuery Library (FHIR Library profile) into the parts the engine
2//! needs: the SQL string, parameter declarations, and depends-on ViewDefinitions.
3//!
4//! See <https://build.fhir.org/ig/FHIR/sql-on-fhir-v2/StructureDefinition-SQLQuery.html>
5
6use base64::Engine;
7use serde_json::Value;
8
9use super::SqlQueryError;
10
11/// `Library.type.coding.system` value the SQLQuery profile fixes.
12pub const LIBRARY_TYPE_SYSTEM: &str = "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes";
13/// `Library.type.coding.code` value the SQLQuery profile fixes.
14pub const LIBRARY_TYPE_CODE: &str = "sql-query";
15
16/// SQL dialect the engine speaks. Used to pick the most specific
17/// `application/sql;dialect=…` content attachment.
18pub const ENGINE_DIALECT: &str = "sqlite";
19
20/// One row's worth of metadata from `Library.parameter`. `use=in` only.
21#[derive(Debug, Clone)]
22pub struct LibraryParameter {
23    pub name: String,
24    /// FHIR `code` element — `string`, `integer`, `integer64`, `boolean`,
25    /// `decimal`, `date`, `dateTime`, `instant`, `time`, etc. Required by the
26    /// SQLQuery profile (1..1) — missing here is a malformed Library.
27    pub type_code: String,
28    /// Was the parameter declared with a `default[X]` value? If so we treat
29    /// it as optional. The SQLQuery profile does not document defaults; this
30    /// is a forward-compatible read for any `default*` field on the entry.
31    pub has_default: bool,
32    /// Default value as raw JSON, if any.
33    pub default_value: Option<Value>,
34}
35
36/// A `depends-on` entry in the Library's `relatedArtifact`. The SQLQuery
37/// profile requires `relatedArtifact.resource` to be a canonical URL — inline
38/// ViewDefinition resources are **not** part of the profile and are rejected.
39#[derive(Debug, Clone)]
40pub struct DependsOnView {
41    /// Table alias the SQL references. Constrained to `^[A-Za-z][A-Za-z0-9_]*$`
42    /// by the profile (`sql-name` invariant).
43    pub label: String,
44    /// Canonical URL pointing to a ViewDefinition the server resolves.
45    pub url: String,
46}
47
48/// A parsed SQLQuery Library.
49#[derive(Debug, Clone)]
50pub struct SqlQueryLibrary {
51    pub sql: String,
52    pub parameters: Vec<LibraryParameter>,
53    pub depends_on: Vec<DependsOnView>,
54}
55
56/// Parses a Library resource JSON into a `SqlQueryLibrary`.
57///
58/// Enforces the SQLQuery profile constraints:
59/// - `resourceType == "Library"`
60/// - `Library.type.coding[*]` contains `{system: LibraryTypesCodes, code: sql-query}`
61/// - At least one `content` attachment with `contentType` starting with `application/sql`
62/// - All `relatedArtifact[type="depends-on"]` entries have a canonical URL `resource`
63///   and a `label` matching `^[A-Za-z][A-Za-z0-9_]*$`
64/// - All `Library.parameter[use="in"]` entries declare a `type`
65pub fn parse_sqlquery_library(library_json: &Value) -> Result<SqlQueryLibrary, SqlQueryError> {
66    if library_json.get("resourceType").and_then(|v| v.as_str()) != Some("Library") {
67        return Err(SqlQueryError::MalformedLibrary(
68            "resourceType must be 'Library'".to_string(),
69        ));
70    }
71
72    validate_library_type(library_json)?;
73
74    let sql = extract_sql(library_json)?;
75    let parameters = extract_parameters(library_json)?;
76    let depends_on = extract_depends_on(library_json)?;
77
78    Ok(SqlQueryLibrary {
79        sql,
80        parameters,
81        depends_on,
82    })
83}
84
85/// Spec: `Library.type` SHALL carry `LibraryTypesCodes#sql-query`.
86fn validate_library_type(library_json: &Value) -> Result<(), SqlQueryError> {
87    let codings = library_json
88        .get("type")
89        .and_then(|t| t.get("coding"))
90        .and_then(|c| c.as_array())
91        .ok_or_else(|| {
92            SqlQueryError::MalformedLibrary(
93                "Library.type.coding[] is required and must include LibraryTypesCodes#sql-query"
94                    .to_string(),
95            )
96        })?;
97    let ok = codings.iter().any(|c| {
98        let code = c.get("code").and_then(|v| v.as_str());
99        let system = c.get("system").and_then(|v| v.as_str());
100        code == Some(LIBRARY_TYPE_CODE) && (system.is_none() || system == Some(LIBRARY_TYPE_SYSTEM))
101    });
102    if !ok {
103        return Err(SqlQueryError::MalformedLibrary(format!(
104            "Library.type must include coding {{system: {LIBRARY_TYPE_SYSTEM}, code: {LIBRARY_TYPE_CODE}}}"
105        )));
106    }
107    Ok(())
108}
109
110/// Spec dialect-selection: prefer an `application/sql;dialect=<ENGINE_DIALECT>`
111/// attachment, fall back to bare `application/sql`, then any other
112/// `application/sql*` variant.
113fn extract_sql(library_json: &Value) -> Result<String, SqlQueryError> {
114    let content = library_json
115        .get("content")
116        .and_then(|c| c.as_array())
117        .ok_or(SqlQueryError::MissingSql)?;
118
119    // Bucket attachments by specificity so we can pick deterministically.
120    let mut dialect_match: Option<&Value> = None;
121    let mut bare: Option<&Value> = None;
122    let mut other: Option<&Value> = None;
123
124    for entry in content {
125        let ct = entry
126            .get("contentType")
127            .and_then(|v| v.as_str())
128            .unwrap_or("");
129        if !ct.starts_with("application/sql") {
130            // Profile constraint `sql-must-be-sql-expressions` says every
131            // content.contentType SHALL start with `application/sql`. We're
132            // tolerant for now (skip), since the entire content array isn't
133            // required to be pure SQL in practice; but we don't pick this one.
134            continue;
135        }
136        if let Some(rest) = ct.strip_prefix("application/sql").map(str::trim_start) {
137            if rest.is_empty() {
138                if bare.is_none() {
139                    bare = Some(entry);
140                }
141            } else if parses_dialect(rest, ENGINE_DIALECT) {
142                dialect_match = Some(entry);
143            } else if other.is_none() {
144                other = Some(entry);
145            }
146        }
147    }
148
149    let chosen = dialect_match
150        .or(bare)
151        .or(other)
152        .ok_or(SqlQueryError::MissingSql)?;
153    read_sql_from_attachment(chosen)
154}
155
156/// Returns `true` if a contentType suffix like `;dialect=sqlite` matches
157/// `dialect`. Handles whitespace and quoted values.
158fn parses_dialect(suffix: &str, dialect: &str) -> bool {
159    // `;dialect=sqlite`, `; dialect=SQLite`, `;dialect="sqlite"`
160    let suffix = suffix.trim_start_matches(';').trim();
161    for part in suffix.split(';') {
162        let kv = part.trim();
163        if let Some(value) = kv.strip_prefix("dialect=") {
164            let v = value.trim_matches('"').trim();
165            if v.eq_ignore_ascii_case(dialect) {
166                return true;
167            }
168        }
169    }
170    false
171}
172
173fn read_sql_from_attachment(entry: &Value) -> Result<String, SqlQueryError> {
174    // Preferred per profile: base64 `data`.
175    if let Some(data_b64) = entry.get("data").and_then(|v| v.as_str()) {
176        let bytes = base64::engine::general_purpose::STANDARD
177            .decode(data_b64)
178            .map_err(|e| {
179                SqlQueryError::MalformedLibrary(format!(
180                    "Library.content[].data is not valid base64: {e}"
181                ))
182            })?;
183        return String::from_utf8(bytes).map_err(|e| {
184            SqlQueryError::MalformedLibrary(format!("Library.content[].data is not UTF-8: {e}"))
185        });
186    }
187    // Fallback: sql-text extension carrying plain-text SQL.
188    if let Some(extensions) = entry.get("extension").and_then(|v| v.as_array()) {
189        for ext in extensions {
190            let url = ext.get("url").and_then(|v| v.as_str()).unwrap_or("");
191            // Accept either the official URL or a relative form.
192            let is_sql_text = url.ends_with("/sql-text") || url == "sql-text";
193            if is_sql_text {
194                if let Some(s) = ext.get("valueString").and_then(|v| v.as_str()) {
195                    return Ok(s.to_string());
196                }
197            }
198        }
199    }
200    Err(SqlQueryError::MissingSql)
201}
202
203fn extract_parameters(library_json: &Value) -> Result<Vec<LibraryParameter>, SqlQueryError> {
204    let Some(arr) = library_json.get("parameter").and_then(|v| v.as_array()) else {
205        return Ok(Vec::new());
206    };
207
208    let mut out = Vec::new();
209    for p in arr {
210        if p.get("use").and_then(|v| v.as_str()) != Some("in") {
211            // SQLQuery profile only defines `use=in` parameters. `out` (and
212            // any other value) has no defined semantics for $sqlquery-run,
213            // so silently skip rather than reject — keeps us forward-
214            // compatible with future profile additions.
215            continue;
216        }
217        let name = p.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
218            SqlQueryError::MalformedLibrary(
219                "Library.parameter[*].name is required for use=in entries".to_string(),
220            )
221        })?;
222        let type_code = p.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
223            SqlQueryError::MalformedLibrary(format!(
224                "Library.parameter[name='{name}'].type is required (profile cardinality 1..1)"
225            ))
226        })?;
227        let (has_default, default_value) = read_default(p);
228        out.push(LibraryParameter {
229            name: name.to_string(),
230            type_code: type_code.to_string(),
231            has_default,
232            default_value,
233        });
234    }
235    Ok(out)
236}
237
238fn read_default(entry: &Value) -> (bool, Option<Value>) {
239    if let Some(obj) = entry.as_object() {
240        for (k, v) in obj {
241            if let Some(rest) = k.strip_prefix("default") {
242                if !rest.is_empty() {
243                    return (true, Some(v.clone()));
244                }
245            }
246        }
247    }
248    (false, None)
249}
250
251fn extract_depends_on(library_json: &Value) -> Result<Vec<DependsOnView>, SqlQueryError> {
252    let Some(rels) = library_json
253        .get("relatedArtifact")
254        .and_then(|v| v.as_array())
255    else {
256        return Ok(Vec::new());
257    };
258
259    let mut out = Vec::new();
260    let mut seen_labels = std::collections::HashSet::new();
261    for entry in rels {
262        if entry.get("type").and_then(|v| v.as_str()) != Some("depends-on") {
263            continue;
264        }
265        let label = entry
266            .get("label")
267            .and_then(|v| v.as_str())
268            .ok_or(SqlQueryError::MissingDependsOnLabel)?;
269        if !is_valid_sql_label(label) {
270            return Err(SqlQueryError::MalformedLibrary(format!(
271                "relatedArtifact.label '{label}' violates the sql-name constraint \
272                 (^[A-Za-z][A-Za-z0-9_]*$)"
273            )));
274        }
275        if !seen_labels.insert(label.to_string()) {
276            return Err(SqlQueryError::MalformedLibrary(format!(
277                "duplicate depends-on label '{label}'"
278            )));
279        }
280        // Profile pins `relatedArtifact.resource` to canonical([Resource]).
281        let url = entry
282            .get("resource")
283            .and_then(|v| v.as_str())
284            .ok_or_else(|| {
285                SqlQueryError::MalformedLibrary(format!(
286                    "relatedArtifact label='{label}' must carry a canonical URL in 'resource'; \
287                     inline ViewDefinition resources are not part of the SQLQuery profile"
288                ))
289            })?;
290        if url.is_empty() {
291            return Err(SqlQueryError::MalformedLibrary(format!(
292                "relatedArtifact label='{label}' has an empty 'resource' canonical URL"
293            )));
294        }
295        out.push(DependsOnView {
296            label: label.to_string(),
297            url: url.to_string(),
298        });
299    }
300    Ok(out)
301}
302
303/// Spec invariant `sql-name`: `^[A-Za-z][A-Za-z0-9_]*$`.
304pub fn is_valid_sql_label(name: &str) -> bool {
305    let mut chars = name.chars();
306    let Some(first) = chars.next() else {
307        return false;
308    };
309    if !first.is_ascii_alphabetic() {
310        return false;
311    }
312    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use base64::engine::general_purpose::STANDARD;
319    use serde_json::json;
320
321    fn library_skeleton(sql: &str) -> Value {
322        let data = STANDARD.encode(sql.as_bytes());
323        json!({
324            "resourceType": "Library",
325            "type": {"coding": [{"system": LIBRARY_TYPE_SYSTEM, "code": LIBRARY_TYPE_CODE}]},
326            "content": [{ "contentType": "application/sql", "data": data }]
327        })
328    }
329
330    #[test]
331    fn parses_minimal_library() {
332        let lib = library_skeleton("SELECT 1");
333        let parsed = parse_sqlquery_library(&lib).unwrap();
334        assert_eq!(parsed.sql, "SELECT 1");
335        assert!(parsed.parameters.is_empty());
336        assert!(parsed.depends_on.is_empty());
337    }
338
339    #[test]
340    fn parses_sql_text_extension() {
341        let mut lib = library_skeleton("ignored");
342        lib["content"] = json!([{
343            "contentType": "application/sql",
344            "extension": [{
345                "url": "https://sql-on-fhir.org/ig/StructureDefinition/sql-text",
346                "valueString": "SELECT 2"
347            }]
348        }]);
349        let parsed = parse_sqlquery_library(&lib).unwrap();
350        assert_eq!(parsed.sql, "SELECT 2");
351    }
352
353    #[test]
354    fn picks_engine_dialect_over_default() {
355        let lib_sqlite = STANDARD.encode("SELECT sqlite_version()");
356        let lib_default = STANDARD.encode("SELECT 'default'");
357        let lib_pg = STANDARD.encode("SELECT pg_version()");
358        let mut lib = library_skeleton("placeholder");
359        lib["content"] = json!([
360            { "contentType": "application/sql;dialect=postgresql", "data": lib_pg },
361            { "contentType": "application/sql", "data": lib_default },
362            { "contentType": "application/sql;dialect=sqlite", "data": lib_sqlite },
363        ]);
364        let parsed = parse_sqlquery_library(&lib).unwrap();
365        assert_eq!(parsed.sql, "SELECT sqlite_version()");
366    }
367
368    #[test]
369    fn falls_back_to_bare_when_no_dialect_match() {
370        let lib_default = STANDARD.encode("SELECT 'default'");
371        let lib_pg = STANDARD.encode("SELECT pg_version()");
372        let mut lib = library_skeleton("placeholder");
373        lib["content"] = json!([
374            { "contentType": "application/sql;dialect=postgresql", "data": lib_pg },
375            { "contentType": "application/sql", "data": lib_default },
376        ]);
377        let parsed = parse_sqlquery_library(&lib).unwrap();
378        assert_eq!(parsed.sql, "SELECT 'default'");
379    }
380
381    #[test]
382    fn rejects_non_library() {
383        let err = parse_sqlquery_library(&json!({"resourceType": "Bundle"})).unwrap_err();
384        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
385    }
386
387    #[test]
388    fn rejects_library_without_sql_query_type() {
389        let mut lib = library_skeleton("SELECT 1");
390        lib["type"] = json!({"coding": [{"code": "logic-library"}]});
391        let err = parse_sqlquery_library(&lib).unwrap_err();
392        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
393    }
394
395    #[test]
396    fn rejects_library_without_type() {
397        let mut lib = library_skeleton("SELECT 1");
398        lib.as_object_mut().unwrap().remove("type");
399        let err = parse_sqlquery_library(&lib).unwrap_err();
400        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
401    }
402
403    #[test]
404    fn rejects_no_sql() {
405        let mut lib = library_skeleton("ignored");
406        lib.as_object_mut().unwrap().remove("content");
407        let err = parse_sqlquery_library(&lib).unwrap_err();
408        assert!(matches!(err, SqlQueryError::MissingSql));
409    }
410
411    #[test]
412    fn parses_parameters_and_depends_on() {
413        let mut lib = library_skeleton("SELECT * FROM t");
414        lib["parameter"] = json!([
415            {"name": "p1", "use": "in", "type": "integer"},
416            {"name": "p2", "use": "out", "type": "string"} // skipped silently
417        ]);
418        lib["relatedArtifact"] = json!([
419            {"type": "depends-on", "label": "t", "resource": "http://example.org/VD"},
420            {"type": "documentation", "label": "ignored"}
421        ]);
422        let parsed = parse_sqlquery_library(&lib).unwrap();
423        assert_eq!(parsed.parameters.len(), 1);
424        assert_eq!(parsed.parameters[0].name, "p1");
425        assert_eq!(parsed.parameters[0].type_code, "integer");
426        assert_eq!(parsed.depends_on.len(), 1);
427        assert_eq!(parsed.depends_on[0].label, "t");
428        assert_eq!(parsed.depends_on[0].url, "http://example.org/VD");
429    }
430
431    #[test]
432    fn rejects_parameter_without_type() {
433        let mut lib = library_skeleton("SELECT 1");
434        lib["parameter"] = json!([{"name": "p1", "use": "in"}]);
435        let err = parse_sqlquery_library(&lib).unwrap_err();
436        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
437    }
438
439    #[test]
440    fn rejects_depends_on_without_label() {
441        let mut lib = library_skeleton("SELECT 1");
442        lib["relatedArtifact"] = json!([
443            {"type": "depends-on", "resource": "http://example.org/VD"}
444        ]);
445        let err = parse_sqlquery_library(&lib).unwrap_err();
446        assert!(matches!(err, SqlQueryError::MissingDependsOnLabel));
447    }
448
449    #[test]
450    fn rejects_label_violating_sql_name_invariant() {
451        let mut lib = library_skeleton("SELECT 1");
452        lib["relatedArtifact"] = json!([
453            {"type": "depends-on", "label": "1bad", "resource": "http://example.org/VD"}
454        ]);
455        let err = parse_sqlquery_library(&lib).unwrap_err();
456        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
457    }
458
459    #[test]
460    fn rejects_duplicate_label() {
461        let mut lib = library_skeleton("SELECT 1");
462        lib["relatedArtifact"] = json!([
463            {"type": "depends-on", "label": "t", "resource": "http://example.org/A"},
464            {"type": "depends-on", "label": "t", "resource": "http://example.org/B"}
465        ]);
466        let err = parse_sqlquery_library(&lib).unwrap_err();
467        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
468    }
469
470    #[test]
471    fn rejects_inline_view_definition() {
472        let mut lib = library_skeleton("SELECT 1");
473        lib["relatedArtifact"] = json!([
474            {"type": "depends-on", "label": "t", "resource": {"resourceType": "ViewDefinition"}}
475        ]);
476        let err = parse_sqlquery_library(&lib).unwrap_err();
477        assert!(matches!(err, SqlQueryError::MalformedLibrary(_)));
478    }
479
480    #[test]
481    fn label_invariant_helper() {
482        assert!(is_valid_sql_label("abc"));
483        assert!(is_valid_sql_label("A1_b"));
484        assert!(!is_valid_sql_label(""));
485        assert!(!is_valid_sql_label("1abc"));
486        assert!(!is_valid_sql_label("_abc"));
487        assert!(!is_valid_sql_label("a-b"));
488        assert!(!is_valid_sql_label("a b"));
489        assert!(!is_valid_sql_label("a\"b"));
490    }
491}