1use base64::Engine;
7use serde_json::Value;
8
9use super::SqlQueryError;
10
11pub const LIBRARY_TYPE_SYSTEM: &str = "https://sql-on-fhir.org/ig/CodeSystem/LibraryTypesCodes";
13pub const LIBRARY_TYPE_CODE: &str = "sql-query";
15
16pub const ENGINE_DIALECT: &str = "sqlite";
19
20#[derive(Debug, Clone)]
22pub struct LibraryParameter {
23 pub name: String,
24 pub type_code: String,
28 pub has_default: bool,
32 pub default_value: Option<Value>,
34}
35
36#[derive(Debug, Clone)]
40pub struct DependsOnView {
41 pub label: String,
44 pub url: String,
46}
47
48#[derive(Debug, Clone)]
50pub struct SqlQueryLibrary {
51 pub sql: String,
52 pub parameters: Vec<LibraryParameter>,
53 pub depends_on: Vec<DependsOnView>,
54}
55
56pub 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
85fn 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
110fn 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 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 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
156fn parses_dialect(suffix: &str, dialect: &str) -> bool {
159 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 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 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 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 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 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
303pub 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"} ]);
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}