1use base64::Engine;
7use serde_json::Value;
8
9use super::SqlQueryError;
10use crate::canonical::{LEGACY_LIBRARY_TYPES_CODE_SYSTEM, LIBRARY_TYPES_CODE_SYSTEM};
11
12pub const LIBRARY_TYPE_SYSTEM: &str = LIBRARY_TYPES_CODE_SYSTEM;
14pub const LIBRARY_TYPE_CODE: &str = "sql-query";
16pub const LIBRARY_TYPE_CODE_SQL_VIEW: &str = "sql-view";
22
23pub const ENGINE_DIALECT: &str = "sqlite";
26
27#[derive(Debug, Clone)]
29pub struct LibraryParameter {
30 pub name: String,
31 pub type_code: String,
35 pub has_default: bool,
39 pub default_value: Option<Value>,
41}
42
43#[derive(Debug, Clone)]
47pub struct DependsOnView {
48 pub label: String,
51 pub url: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct SqlQueryLibrary {
58 pub sql: String,
59 pub parameters: Vec<LibraryParameter>,
60 pub depends_on: Vec<DependsOnView>,
61}
62
63pub 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
92fn 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 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
132fn 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 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 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
178fn parses_dialect(suffix: &str, dialect: &str) -> bool {
181 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 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 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 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 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 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
325pub 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"} ]);
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}