Skip to main content

helios_persistence/sof/
compiler.rs

1//! ViewDefinition compiler (SQLite/PostgreSQL SQL and MongoDB pipelines).
2//!
3//! Thin façade over the IR-based pipeline:
4//!
5//! 1. [`build_plan`] walks the ViewDefinition JSON and produces a
6//!    [`PlanNode`](super::ir::PlanNode) tree plus the resolved
7//!    `ViewDefinition.constant[]` values. The [`CompileTarget`] tunes
8//!    target-specific lowering (e.g. trailing-`[N]` forEach).
9//! 2. The emitter lowers the plan to the target form: [`emit_plan`] for SQL via
10//!    the [`Dialect`] trait, or [`emit_mongo`](super::emit_mongo::emit_mongo)
11//!    for a MongoDB aggregation pipeline.
12//!
13//! Returns [`SofError::Uncompilable`] for FHIRPath constructs the in-DB
14//! pipeline doesn't yet handle (e.g. `where(crit)` chains, the boundary
15//! functions without a column type hint, deeper unionAll/repeat nesting).
16//! There is no in-process fallback — the REST handler maps these errors
17//! to `422 Unprocessable Entity`.
18
19use 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/// SQL dialect to target during compilation.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SqlDialect {
31    /// SQLite: `json_extract`, `json_each`, positional `?1`/`?2` params.
32    Sqlite,
33    /// PostgreSQL: JSONB operators (`->>`/ `#>>`), `jsonb_array_elements`, `$1`/`$2` params.
34    Postgres,
35}
36
37/// Backend a ViewDefinition is being compiled for. Drives target-specific
38/// lowering decisions in [`build_plan`] (e.g. whether trailing-`[N]` forEach
39/// paths may use a correlated subquery) and selects the emitter.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CompileTarget {
42    /// SQLite SQL emitter.
43    Sqlite,
44    /// PostgreSQL SQL emitter.
45    Postgres,
46    /// MongoDB aggregation-pipeline emitter.
47    #[cfg(feature = "mongodb")]
48    Mongo,
49}
50
51impl CompileTarget {
52    /// Whether the target can index a flattened collection via a correlated
53    /// subquery in `FROM`. SQL backends can (`ScalarFromChain`); the MongoDB
54    /// emitter instead carries `flat_index` on the unnest and lowers it to
55    /// `$arrayElemAt`, so `build_plan` must NOT produce `ScalarFromChain` nodes
56    /// for it.
57    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/// Output of a successful ViewDefinition compilation.
67#[derive(Debug, Clone)]
68pub struct CompiledQuery {
69    /// Parameterised SQL.
70    ///
71    /// - SQLite: `?1 = tenant_id`, `?2 = resource_type`, `?3..N = constants`
72    /// - PostgreSQL: `$1 = tenant_id`, `$2 = resource_type`, `$3..N = constants`
73    pub sql: String,
74    /// Column names in the order they appear in the SELECT list.
75    pub columns: Vec<String>,
76    /// Resolved `ViewDefinition.constant[]` values, in allocation order.
77    /// Bound by the runners as `$3..` / `?3..` after `tenant_id` and
78    /// `resource_type`.
79    pub constants: Vec<super::ir::LitValue>,
80}
81
82/// Compiled SQL-on-FHIR view, in the form the target backend executes:
83/// parameterised SQL, or a MongoDB aggregation pipeline.
84#[derive(Debug, Clone)]
85pub enum CompiledView {
86    /// SQL text + bind constants for the SQLite / PostgreSQL runners.
87    Sql(CompiledQuery),
88    /// Aggregation pipeline for the MongoDB runner.
89    #[cfg(feature = "mongodb")]
90    Mongo(CompiledPipeline),
91}
92
93/// Output of compiling a ViewDefinition to a MongoDB aggregation pipeline.
94#[cfg(feature = "mongodb")]
95#[derive(Debug, Clone)]
96pub struct CompiledPipeline {
97    /// Aggregation stages, ready to pass to `Collection::aggregate`. The leading
98    /// `$match` already constrains `tenant_id`/`resource_type`/`is_deleted`.
99    pub pipeline: Vec<mongodb::bson::Document>,
100    /// Column names in `select` order (the keys of the final `$project`).
101    pub columns: Vec<String>,
102    /// Resolved `ViewDefinition.constant[]` values, in allocation order.
103    ///
104    /// MongoDB has no out-of-band bind parameters, so the emitter inlines these
105    /// as BSON literals; they are surfaced here for parity/diagnostics only.
106    pub constants: Vec<super::ir::LitValue>,
107}
108
109/// Picks the dialect implementation for a given [`SqlDialect`].
110fn 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
117/// Compiles a raw ViewDefinition JSON value into a [`CompiledQuery`] for SQLite.
118///
119/// Shorthand for `compile_view_definition_dialect(view_json, SqlDialect::Sqlite,
120/// FhirVersion::default_enabled())`.
121pub 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
129/// Compiles a raw ViewDefinition JSON value into a [`CompiledQuery`] for the given dialect.
130///
131/// `fhir_version` controls which generated `get_field_type` lookup table the
132/// compile-time cardinality validator consults. Pass the configured server
133/// default when calling from a runner.
134///
135/// # Errors
136///
137/// Returns [`SofError::Uncompilable`] for any unsupported construct.
138/// Returns [`SofError::InvalidViewDefinition`] if required fields are missing.
139pub 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
155/// Compiles a ViewDefinition for an arbitrary [`CompileTarget`], returning the
156/// target-appropriate [`CompiledView`]. Single funnel through [`build_plan`]
157/// so every target shares the JSON→IR lowering.
158fn 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            // The dialect is unused on the Mongo path (build_plan only consults
182            // it inside the correlated-subquery lowering, which Mongo skips), so
183            // a SQLite dialect serves purely as a never-called placeholder.
184            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/// Compiles a raw ViewDefinition JSON value into a MongoDB aggregation pipeline.
197///
198/// # Errors
199///
200/// Returns [`SofError::Uncompilable`] for constructs the Mongo emitter does not
201/// yet support (e.g. `lowBoundary`/`highBoundary`, `repeat:`, collections).
202#[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// ============================================================================
214// Tests
215// ============================================================================
216
217#[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    // --- Happy path ---
227
228    #[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    // --- unionAll (G8: now compiles to SQL UNION ALL) ---
379
380    #[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        // A column whose path is a bare string literal compiles to a constant
402        // projection — `'hello'` is a valid FHIRPath expression even if
403        // unusual as a column.path.
404        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        // `name.exists()` in a column path lowers to an existence predicate.
417        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        // Sibling forEach clauses produce a cartesian product via two
431        // sequential lateral unnests off `r.data` — one per clause.
432        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        // First unnest keeps the `fe` alias (legacy), second uses `fe2`.
444        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        // Top-level `where: [{path: "active"}]` lowers to a boolean coercion
459        // around the bare field — FHIRPath's three-valued logic boundary is
460        // applied as `IS TRUE` so empty/NULL filter the row out.
461        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        // SQLite truthy boundary doesn't use `IS TRUE` (which is strict-typed
470        // in some dialects) — it checks IS NOT NULL + non-zero / not 'false'.
471        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    // -----------------------------------------------------------------------
491    // PostgreSQL dialect golden tests
492    // -----------------------------------------------------------------------
493
494    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        // The compiler emits `coalesce(<array-first>, <plain>)` for two-Field
524        // paths so navigation through arrays (e.g. `name.family`) auto-picks
525        // the first element when the intermediate is array-shaped.
526        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        // SoF `repeat:` directive lowers to a `WITH RECURSIVE … SELECT`
728        // shape; the CTE projects (rid, node) and the outer SELECT joins
729        // back to `resources r` to resolve sibling root columns.
730        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        // PG version of test_accepts_exists_function_call_path — confirms
750        // `.exists()` lowers to an `IS NOT NULL` predicate.
751        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}