1use helios_fhir::FhirVersion;
20use serde_json::Value;
21
22use crate::core::sof_runner::SofError;
23
24use super::compile_view::build_plan;
25use super::dialect::{Dialect, PgDialect, SqliteDialect};
26use super::emit::emit_plan;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SqlDialect {
31 Sqlite,
33 Postgres,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CompileTarget {
42 Sqlite,
44 Postgres,
46 #[cfg(feature = "mongodb")]
48 Mongo,
49}
50
51impl CompileTarget {
52 pub(super) fn supports_correlated_from_subqueries(self) -> bool {
58 match self {
59 CompileTarget::Sqlite | CompileTarget::Postgres => true,
60 #[cfg(feature = "mongodb")]
61 CompileTarget::Mongo => false,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct CompiledQuery {
69 pub sql: String,
74 pub columns: Vec<String>,
76 pub constants: Vec<super::ir::LitValue>,
80}
81
82#[derive(Debug, Clone)]
85pub enum CompiledView {
86 Sql(CompiledQuery),
88 #[cfg(feature = "mongodb")]
90 Mongo(CompiledPipeline),
91}
92
93#[cfg(feature = "mongodb")]
95#[derive(Debug, Clone)]
96pub struct CompiledPipeline {
97 pub pipeline: Vec<mongodb::bson::Document>,
100 pub columns: Vec<String>,
102 pub constants: Vec<super::ir::LitValue>,
107}
108
109fn dialect_for(d: SqlDialect) -> Box<dyn Dialect> {
111 match d {
112 SqlDialect::Sqlite => Box::new(SqliteDialect),
113 SqlDialect::Postgres => Box::new(PgDialect),
114 }
115}
116
117pub fn compile_view_definition(view_json: &Value) -> Result<CompiledQuery, SofError> {
122 compile_view_definition_dialect(
123 view_json,
124 SqlDialect::Sqlite,
125 FhirVersion::default_enabled(),
126 )
127}
128
129pub fn compile_view_definition_dialect(
140 view_json: &Value,
141 dialect: SqlDialect,
142 fhir_version: FhirVersion,
143) -> Result<CompiledQuery, SofError> {
144 let target = match dialect {
145 SqlDialect::Sqlite => CompileTarget::Sqlite,
146 SqlDialect::Postgres => CompileTarget::Postgres,
147 };
148 match compile_view_target(view_json, target, fhir_version)? {
149 CompiledView::Sql(q) => Ok(q),
150 #[cfg(feature = "mongodb")]
151 CompiledView::Mongo(_) => unreachable!("SQL dialect never compiles to a Mongo pipeline"),
152 }
153}
154
155fn compile_view_target(
159 view_json: &Value,
160 target: CompileTarget,
161 fhir_version: FhirVersion,
162) -> Result<CompiledView, SofError> {
163 match target {
164 CompileTarget::Sqlite | CompileTarget::Postgres => {
165 let dialect = if target == CompileTarget::Postgres {
166 SqlDialect::Postgres
167 } else {
168 SqlDialect::Sqlite
169 };
170 let dial = dialect_for(dialect);
171 let (plan, constants) = build_plan(view_json, dial.as_ref(), target, fhir_version)?;
172 let emitted = emit_plan(&plan, dial.as_ref())?;
173 Ok(CompiledView::Sql(CompiledQuery {
174 sql: emitted.sql,
175 columns: emitted.columns,
176 constants,
177 }))
178 }
179 #[cfg(feature = "mongodb")]
180 CompileTarget::Mongo => {
181 let dial = dialect_for(SqlDialect::Sqlite);
185 let (plan, constants) = build_plan(view_json, dial.as_ref(), target, fhir_version)?;
186 let emitted = super::emit_mongo::emit_mongo(&plan, &constants)?;
187 Ok(CompiledView::Mongo(CompiledPipeline {
188 pipeline: emitted.pipeline,
189 columns: emitted.columns,
190 constants,
191 }))
192 }
193 }
194}
195
196#[cfg(feature = "mongodb")]
203pub fn compile_view_definition_mongo(
204 view_json: &Value,
205 fhir_version: FhirVersion,
206) -> Result<CompiledPipeline, SofError> {
207 match compile_view_target(view_json, CompileTarget::Mongo, fhir_version)? {
208 CompiledView::Mongo(p) => Ok(p),
209 CompiledView::Sql(_) => unreachable!("Mongo target never compiles to SQL"),
210 }
211}
212
213#[cfg(test)]
218mod tests {
219 use super::*;
220 use serde_json::json;
221
222 fn compile(view: serde_json::Value) -> Result<CompiledQuery, SofError> {
223 compile_view_definition(&view)
224 }
225
226 #[test]
229 fn test_flat_single_column() {
230 let view = json!({
231 "resourceType": "ViewDefinition",
232 "resource": "Patient",
233 "status": "active",
234 "select": [{"column": [{"path": "id", "name": "id", "type": "string"}]}]
235 });
236 let q = compile(view).unwrap();
237 assert_eq!(q.columns, vec!["id"]);
238 assert!(
239 q.sql.contains("json_extract(r.data, '$.id') AS \"id\""),
240 "{}",
241 q.sql
242 );
243 assert!(q.sql.contains("r.tenant_id = ?1"), "{}", q.sql);
244 assert!(q.sql.contains("r.resource_type = ?2"), "{}", q.sql);
245 assert!(q.sql.contains("r.is_deleted = 0"), "{}", q.sql);
246 }
247
248 #[test]
249 fn test_flat_multiple_columns() {
250 let view = json!({
251 "resourceType": "ViewDefinition",
252 "resource": "Patient",
253 "status": "active",
254 "select": [{
255 "column": [
256 {"path": "id", "name": "id"},
257 {"path": "gender", "name": "gender"},
258 {"path": "birthDate", "name": "dob"}
259 ]
260 }]
261 });
262 let q = compile(view).unwrap();
263 assert_eq!(q.columns, vec!["id", "gender", "dob"]);
264 assert!(
265 q.sql.contains("json_extract(r.data, '$.id') AS \"id\""),
266 "{}",
267 q.sql
268 );
269 assert!(
270 q.sql
271 .contains("json_extract(r.data, '$.gender') AS \"gender\""),
272 "{}",
273 q.sql
274 );
275 assert!(
276 q.sql
277 .contains("json_extract(r.data, '$.birthDate') AS \"dob\""),
278 "{}",
279 q.sql
280 );
281 }
282
283 #[test]
284 fn test_multiple_flat_select_clauses() {
285 let view = json!({
286 "resourceType": "ViewDefinition",
287 "resource": "Patient",
288 "status": "active",
289 "select": [
290 {"column": [{"path": "id", "name": "id"}]},
291 {"column": [{"path": "gender", "name": "gender"}]}
292 ]
293 });
294 let q = compile(view).unwrap();
295 assert_eq!(q.columns, vec!["id", "gender"]);
296 }
297
298 #[test]
299 fn test_for_each_produces_join() {
300 let view = json!({
301 "resourceType": "ViewDefinition",
302 "resource": "Patient",
303 "status": "active",
304 "select": [{
305 "forEach": "name",
306 "column": [
307 {"path": "family", "name": "family"},
308 {"path": "use", "name": "use"}
309 ]
310 }]
311 });
312 let q = compile(view).unwrap();
313 assert_eq!(q.columns, vec!["family", "use"]);
314 assert!(
315 q.sql.contains("JOIN json_each(r.data, '$.name') fe ON 1=1"),
316 "{}",
317 q.sql
318 );
319 assert!(
320 q.sql
321 .contains("json_extract(fe.value, '$.family') AS \"family\""),
322 "{}",
323 q.sql
324 );
325 }
326
327 #[test]
328 fn test_for_each_or_null_produces_left_join() {
329 let view = json!({
330 "resourceType": "ViewDefinition",
331 "resource": "Patient",
332 "status": "active",
333 "select": [{
334 "forEachOrNull": "name",
335 "column": [{"path": "family", "name": "family"}]
336 }]
337 });
338 let q = compile(view).unwrap();
339 assert!(
340 q.sql
341 .contains("LEFT JOIN json_each(r.data, '$.name') fe ON 1=1"),
342 "{}",
343 q.sql
344 );
345 }
346
347 #[test]
348 fn test_mixed_root_and_foreach() {
349 let view = json!({
350 "resourceType": "ViewDefinition",
351 "resource": "Patient",
352 "status": "active",
353 "select": [
354 {"column": [{"path": "id", "name": "id"}]},
355 {"forEach": "name", "column": [{"path": "family", "name": "family"}]}
356 ]
357 });
358 let q = compile(view).unwrap();
359 assert_eq!(q.columns, vec!["id", "family"]);
360 assert!(
361 q.sql.contains("json_extract(r.data, '$.id') AS \"id\""),
362 "{}",
363 q.sql
364 );
365 assert!(
366 q.sql
367 .contains("json_extract(fe.value, '$.family') AS \"family\""),
368 "{}",
369 q.sql
370 );
371 assert!(
372 q.sql.contains("JOIN json_each(r.data, '$.name') fe ON 1=1"),
373 "{}",
374 q.sql
375 );
376 }
377
378 #[test]
381 fn test_union_all_compiles_to_sql_union_all() {
382 let view = json!({
383 "resourceType": "ViewDefinition",
384 "resource": "Patient",
385 "status": "active",
386 "select": [{"unionAll": [
387 {"column": [{"path": "id", "name": "id"}]},
388 {"column": [{"path": "id", "name": "id"}]}
389 ]}]
390 });
391 let q = compile(view).unwrap();
392 assert!(
393 q.sql.contains("UNION ALL"),
394 "expected UNION ALL in compiled SQL: {}",
395 q.sql
396 );
397 }
398
399 #[test]
400 fn test_accepts_literal_string_path() {
401 let view = json!({
405 "resourceType": "ViewDefinition",
406 "resource": "Patient",
407 "status": "active",
408 "select": [{"column": [{"path": "'hello'", "name": "x"}]}]
409 });
410 let q = compile(view).unwrap();
411 assert!(q.sql.contains("'hello' AS \"x\""), "{}", q.sql);
412 }
413
414 #[test]
415 fn test_accepts_exists_function_call_path() {
416 let view = json!({
418 "resourceType": "ViewDefinition",
419 "resource": "Patient",
420 "status": "active",
421 "select": [{"column": [{"path": "name.exists()", "name": "has_name"}]}]
422 });
423 let q = compile(view).unwrap();
424 assert!(q.sql.contains("IS NOT NULL"), "{}", q.sql);
425 assert!(q.sql.contains("AS \"has_name\""), "{}", q.sql);
426 }
427
428 #[test]
429 fn test_sibling_foreach_emits_cross_join() {
430 let view = json!({
433 "resourceType": "ViewDefinition",
434 "resource": "Patient",
435 "status": "active",
436 "select": [
437 {"forEach": "name", "column": [{"path": "family", "name": "family"}]},
438 {"forEach": "address", "column": [{"path": "city", "name": "city"}]}
439 ]
440 });
441 let q = compile(view).unwrap();
442 assert_eq!(q.columns, vec!["family", "city"]);
443 assert!(
445 q.sql.contains("JOIN json_each(r.data, '$.name') fe ON"),
446 "{}",
447 q.sql
448 );
449 assert!(
450 q.sql.contains("JOIN json_each(r.data, '$.address') fe2 ON"),
451 "{}",
452 q.sql
453 );
454 }
455
456 #[test]
457 fn test_accepts_bare_boolean_where() {
458 let view = json!({
462 "resourceType": "ViewDefinition",
463 "resource": "Patient",
464 "status": "active",
465 "where": [{"path": "active"}],
466 "select": [{"column": [{"path": "id", "name": "id"}]}]
467 });
468 let q = compile(view).unwrap();
469 assert!(q.sql.contains("IS NOT NULL"), "{}", q.sql);
472 assert!(
473 q.sql.contains("json_extract(r.data, '$.active')"),
474 "{}",
475 q.sql
476 );
477 }
478
479 #[test]
480 fn test_rejects_missing_resource() {
481 let view = json!({
482 "resourceType": "ViewDefinition",
483 "status": "active",
484 "select": [{"column": [{"path": "id", "name": "id"}]}]
485 });
486 let err = compile(view).unwrap_err();
487 assert!(matches!(err, SofError::InvalidViewDefinition(_)), "{err:?}");
488 }
489
490 fn compile_pg(view: serde_json::Value) -> Result<CompiledQuery, SofError> {
495 compile_view_definition_dialect(&view, SqlDialect::Postgres, FhirVersion::default())
496 }
497
498 #[test]
499 fn test_pg_flat_single_column() {
500 let view = json!({
501 "resourceType": "ViewDefinition",
502 "resource": "Patient",
503 "status": "active",
504 "select": [{"column": [{"path": "id", "name": "id", "type": "string"}]}]
505 });
506 let q = compile_pg(view).unwrap();
507 assert_eq!(q.columns, vec!["id"]);
508 assert!(q.sql.contains("r.data->>'id' AS \"id\""), "{}", q.sql);
509 assert!(q.sql.contains("r.tenant_id = $1"), "{}", q.sql);
510 assert!(q.sql.contains("r.resource_type = $2"), "{}", q.sql);
511 assert!(q.sql.contains("r.is_deleted = false"), "{}", q.sql);
512 }
513
514 #[test]
515 fn test_pg_flat_dotted_path() {
516 let view = json!({
517 "resourceType": "ViewDefinition",
518 "resource": "Observation",
519 "status": "active",
520 "select": [{"column": [{"path": "subject.reference", "name": "subject_ref"}]}]
521 });
522 let q = compile_pg(view).unwrap();
523 assert!(
527 q.sql.contains("coalesce(r.data#>>'{subject,0,reference}'"),
528 "{}",
529 q.sql
530 );
531 assert!(
532 q.sql.contains("r.data#>>'{subject,reference}'"),
533 "{}",
534 q.sql
535 );
536 }
537
538 #[test]
539 fn test_pg_foreach_produces_lateral_join() {
540 let view = json!({
541 "resourceType": "ViewDefinition",
542 "resource": "Patient",
543 "status": "active",
544 "select": [{
545 "forEach": "name",
546 "column": [
547 {"path": "family", "name": "family"},
548 {"path": "use", "name": "use_code"}
549 ]
550 }]
551 });
552 let q = compile_pg(view).unwrap();
553 assert_eq!(q.columns, vec!["family", "use_code"]);
554 assert!(
555 q.sql
556 .contains("JOIN LATERAL jsonb_array_elements((CASE WHEN jsonb_typeof(r.data->'name') = 'array' THEN r.data->'name' WHEN jsonb_typeof(r.data->'name') IS NOT NULL THEN jsonb_build_array(r.data->'name') ELSE '[]'::jsonb END)) AS fe(value) ON TRUE"),
557 "{}",
558 q.sql
559 );
560 assert!(
561 q.sql.contains("fe.value->>'family' AS \"family\""),
562 "{}",
563 q.sql
564 );
565 assert!(
566 q.sql.contains("fe.value->>'use' AS \"use_code\""),
567 "{}",
568 q.sql
569 );
570 }
571
572 #[test]
573 fn test_pg_foreach_or_null_produces_left_lateral_join() {
574 let view = json!({
575 "resourceType": "ViewDefinition",
576 "resource": "Patient",
577 "status": "active",
578 "select": [{
579 "forEachOrNull": "name",
580 "column": [{"path": "family", "name": "family"}]
581 }]
582 });
583 let q = compile_pg(view).unwrap();
584 assert!(
585 q.sql.contains(
586 "LEFT JOIN LATERAL jsonb_array_elements((CASE WHEN jsonb_typeof(r.data->'name') = 'array' THEN r.data->'name' WHEN jsonb_typeof(r.data->'name') IS NOT NULL THEN jsonb_build_array(r.data->'name') ELSE '[]'::jsonb END)) AS fe(value) ON TRUE"
587 ),
588 "{}",
589 q.sql
590 );
591 }
592
593 #[test]
594 fn test_pg_mixed_root_and_foreach() {
595 let view = json!({
596 "resourceType": "ViewDefinition",
597 "resource": "Patient",
598 "status": "active",
599 "select": [
600 {"column": [{"path": "id", "name": "id"}]},
601 {"forEach": "name", "column": [{"path": "family", "name": "family"}]}
602 ]
603 });
604 let q = compile_pg(view).unwrap();
605 assert_eq!(q.columns, vec!["id", "family"]);
606 assert!(q.sql.contains("r.data->>'id' AS \"id\""), "{}", q.sql);
607 assert!(
608 q.sql.contains("fe.value->>'family' AS \"family\""),
609 "{}",
610 q.sql
611 );
612 assert!(
613 q.sql
614 .contains("JOIN LATERAL jsonb_array_elements((CASE WHEN jsonb_typeof(r.data->'name') = 'array' THEN r.data->'name' WHEN jsonb_typeof(r.data->'name') IS NOT NULL THEN jsonb_build_array(r.data->'name') ELSE '[]'::jsonb END)) AS fe(value) ON TRUE"),
615 "{}",
616 q.sql
617 );
618 }
619
620 #[test]
621 fn test_repeat_unionall_sql() {
622 let view = json!({
623 "resourceType": "ViewDefinition",
624 "resource": "QuestionnaireResponse",
625 "select": [
626 {"column": [{"name": "id", "path": "id"}]},
627 {"unionAll": [
628 {"repeat": ["item"], "column": [
629 {"name": "type", "path": "'item'"},
630 {"name": "linkId", "path": "linkId"}
631 ]},
632 {"repeat": ["item", "answer.item"], "column": [
633 {"name": "type", "path": "'answer-item'"},
634 {"name": "linkId", "path": "linkId"}
635 ]}
636 ]}
637 ]
638 });
639 let q = compile(view).unwrap();
640 eprintln!("REPEAT-UNION SQL:\n{}", q.sql);
641 }
642
643 #[test]
644 fn test_union_nested_sql() {
645 let view = json!({
646 "resourceType": "ViewDefinition",
647 "resource": "Patient",
648 "select": [{
649 "column": [{"name": "id", "path": "id"}],
650 "unionAll": [
651 {"forEach": "telecom[0]", "column": [{"name": "tel", "path": "value"}]},
652 {"unionAll": [
653 {"forEach": "telecom[0]", "column": [{"name": "tel", "path": "value"}]},
654 {"forEach": "contact.telecom[0]", "column": [{"name": "tel", "path": "value"}]}
655 ]}
656 ]
657 }]
658 });
659 let q = compile(view).unwrap();
660 eprintln!("UNION NESTED SQL:\n{}", q.sql);
661 }
662
663 #[test]
664 fn test_foreach_with_union_all_sql() {
665 let view = json!({
666 "resourceType": "ViewDefinition",
667 "resource": "Patient",
668 "select": [
669 {"column": [{"path": "id", "name": "id"}]},
670 {"forEach": "contact", "unionAll": [
671 {"column": [{"path": "name.family", "name": "name", "type": "string"}]},
672 {"forEach": "name.given", "column": [{"path": "$this", "name": "name", "type": "string"}]}
673 ]}
674 ]
675 });
676 let q = compile(view).unwrap();
677 eprintln!("SQL:\n{}", q.sql);
678 }
679
680 #[test]
681 fn test_collection_emits_full_query() {
682 let view = json!({
683 "resourceType": "ViewDefinition",
684 "resource": "Patient",
685 "select": [{"column": [
686 {"path": "id", "name": "id"},
687 {"path": "name.family", "name": "lf", "type": "string", "collection": true}
688 ]}]
689 });
690 let q = compile(view).unwrap();
691 eprintln!("FULL SQL:\n{}", q.sql);
692 }
693
694 #[test]
695 fn test_collection_true_emits_json_agg() {
696 let view = json!({
697 "resourceType": "ViewDefinition",
698 "resource": "Patient",
699 "select": [{"column": [
700 {"path": "id", "name": "id"},
701 {"path": "name.family", "name": "lf", "type": "string", "collection": true}
702 ]}]
703 });
704 let q = compile(view).unwrap();
705 eprintln!("SQL:\n{}", q.sql);
706 assert!(q.sql.contains("json_group_array"), "{}", q.sql);
707 }
708
709 #[test]
710 fn test_two_segment_path_emits_coalesce() {
711 let view = json!({
712 "resourceType": "ViewDefinition",
713 "resource": "Patient",
714 "status": "active",
715 "select": [{"column": [
716 {"path": "id", "name": "id"},
717 {"path": "name.family", "name": "family"}
718 ]}]
719 });
720 let q = compile(view).unwrap();
721 eprintln!("SQL:\n{}", q.sql);
722 assert!(q.sql.contains("coalesce("), "{}", q.sql);
723 }
724
725 #[test]
726 fn test_repeat_emits_recursive_cte() {
727 let view = json!({
731 "resourceType": "ViewDefinition",
732 "resource": "QuestionnaireResponse",
733 "select": [
734 {"column": [{"path": "id", "name": "id"}]},
735 {"repeat": ["item"], "column": [
736 {"path": "linkId", "name": "linkId"},
737 {"path": "text", "name": "text"}
738 ]}
739 ]
740 });
741 let q = compile(view).unwrap();
742 assert_eq!(q.columns, vec!["id", "linkId", "text"]);
743 assert!(q.sql.contains("WITH RECURSIVE"), "{}", q.sql);
744 assert!(q.sql.contains("UNION ALL"), "{}", q.sql);
745 }
746
747 #[test]
748 fn test_pg_accepts_exists_function_call() {
749 let view = json!({
752 "resourceType": "ViewDefinition",
753 "resource": "Patient",
754 "status": "active",
755 "select": [{"column": [{"path": "name.exists()", "name": "has_name"}]}]
756 });
757 let q = compile_pg(view).unwrap();
758 assert!(q.sql.contains("IS NOT NULL"), "{}", q.sql);
759 assert!(q.sql.contains("AS \"has_name\""), "{}", q.sql);
760 }
761}