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