Skip to main content

helios_sof/
lib.rs

1//! # SQL-on-FHIR Implementation
2//!
3//! This crate provides a complete implementation of the [SQL-on-FHIR
4//! specification](http://hl7.org/fhir/uv/sql-on-fhir),
5//! enabling the transformation of FHIR resources into tabular data using declarative
6//! ViewDefinitions. It supports all major FHIR versions (R4, R4B, R5, R6) through
7//! a version-agnostic abstraction layer.
8
9//!
10//! There are three consumers of this crate:
11//! - [sof_cli](../sof_cli/index.html) - A command-line interface for the SQL-on-FHIR implementation,
12//!   allowing users to execute ViewDefinition transformations on FHIR Bundle resources
13//!   and output the results in various formats.
14//! - [sof_server](../sof_server/index.html) - A stateless HTTP server implementation for the SQL-on-FHIR specification,
15//!   enabling HTTP-based access to ViewDefinition transformation capabilities.
16//! - [hfs](../hfs/index.html) - The full featured Helios FHIR Server.
17//!
18//! ## Architecture
19//!
20//! The SOF crate is organized around these key components:
21//! - **Version-agnostic enums** ([`SofViewDefinition`], [`SofBundle`]): Multi-version containers
22//! - **Processing engine** ([`run_view_definition`]): Core transformation logic
23//! - **Output formats** ([`ContentType`]): Support for CSV, JSON, NDJSON, and Parquet
24//! - **Trait abstractions** ([`ViewDefinitionTrait`], [`BundleTrait`]): Version independence
25//!
26//! ## Key Features
27//!
28//! - **Multi-version FHIR support**: Works with R4, R4B, R5, and R6 resources
29//! - **FHIRPath evaluation**: Complex path expressions for data extraction
30//! - **forEach iteration**: Supports flattening of nested FHIR structures
31//! - **unionAll operations**: Combines multiple select statements
32//! - **Collection handling**: Proper array serialization for multi-valued fields
33//! - **Output formats**: CSV (with/without headers), JSON, NDJSON, Parquet support
34//!
35//! ## Usage Example
36//!
37//! ```rust
38//! # #[cfg(not(target_os = "windows"))]
39//! # {
40//! use helios_sof::{SofViewDefinition, SofBundle, ContentType, run_view_definition};
41//! use helios_fhir::FhirVersion;
42//!
43//! # #[cfg(feature = "R4")]
44//! # {
45//! // Parse a ViewDefinition and Bundle from JSON
46//! let view_definition_json = r#"{
47//!     "resourceType": "ViewDefinition",
48//!     "status": "active",
49//!     "resource": "Patient",
50//!     "select": [{
51//!         "column": [{
52//!             "name": "id",
53//!             "path": "id"
54//!         }, {
55//!             "name": "name",
56//!             "path": "name.family"
57//!         }]
58//!     }]
59//! }"#;
60//!
61//! let bundle_json = r#"{
62//!     "resourceType": "Bundle",
63//!     "type": "collection",
64//!     "entry": [{
65//!         "resource": {
66//!             "resourceType": "Patient",
67//!             "id": "example",
68//!             "name": [{
69//!                 "family": "Doe",
70//!                 "given": ["John"]
71//!             }]
72//!         }
73//!     }]
74//! }"#;
75//!
76//! let view_definition: helios_fhir::r4::ViewDefinition = serde_json::from_str(view_definition_json)?;
77//! let bundle: helios_fhir::r4::Bundle = serde_json::from_str(bundle_json)?;
78//!
79//! // Wrap in version-agnostic containers
80//! let sof_view = SofViewDefinition::R4(view_definition);
81//! let sof_bundle = SofBundle::R4(bundle);
82//!
83//! // Transform to CSV with headers
84//! let csv_output = run_view_definition(
85//!     sof_view,
86//!     sof_bundle,
87//!     ContentType::CsvWithHeader
88//! )?;
89//!
90//! // Check the CSV output
91//! let csv_string = String::from_utf8(csv_output)?;
92//! assert!(csv_string.contains("id,name"));
93//! // CSV values are quoted
94//! assert!(csv_string.contains("example") && csv_string.contains("Doe"));
95//! # }
96//! # }
97//! # Ok::<(), Box<dyn std::error::Error>>(())
98//! ```
99//!
100//! ## Advanced Features
101//!
102//! ### forEach Iteration
103//!
104//! ViewDefinitions can iterate over collections using `forEach` and `forEachOrNull`:
105//!
106//! ```json
107//! {
108//!   "select": [{
109//!     "forEach": "name",
110//!     "column": [{
111//!       "name": "family_name",
112//!       "path": "family"
113//!     }]
114//!   }]
115//! }
116//! ```
117//!
118//! ### Constants and Variables
119//!
120//! Define reusable values in ViewDefinitions:
121//!
122//! ```json
123//! {
124//!   "constant": [{
125//!     "name": "system",
126//!     "valueString": "http://loinc.org"
127//!   }],
128//!   "select": [{
129//!     "where": [{
130//!       "path": "code.coding.system = %system"
131//!     }]
132//!   }]
133//! }
134//! ```
135//!
136//! ### Where Clauses
137//!
138//! Filter resources using FHIRPath expressions:
139//!
140//! ```json
141//! {
142//!   "where": [{
143//!     "path": "active = true"
144//!   }, {
145//!     "path": "birthDate.exists()"
146//!   }]
147//! }
148//! ```
149//!
150//! ## Error Handling
151//!
152//! The crate provides comprehensive error handling through [`SofError`]:
153//!
154//! ```rust,no_run
155//! use helios_sof::{SofError, SofViewDefinition, SofBundle, ContentType, run_view_definition};
156//!
157//! # let view = SofViewDefinition::R4(helios_fhir::r4::ViewDefinition::default());
158//! # let bundle = SofBundle::R4(helios_fhir::r4::Bundle::default());
159//! # let content_type = ContentType::Json;
160//! match run_view_definition(view, bundle, content_type) {
161//!     Ok(output) => {
162//!         // Process successful transformation
163//!     },
164//!     Err(SofError::InvalidViewDefinition(msg)) => {
165//!         eprintln!("ViewDefinition validation failed: {}", msg);
166//!     },
167//!     Err(SofError::FhirPathError(msg)) => {
168//!         eprintln!("FHIRPath evaluation failed: {}", msg);
169//!     },
170//!     Err(e) => {
171//!         eprintln!("Other error: {}", e);
172//!     }
173//! }
174//! ```
175//! ## Feature Flags
176//!
177//! Enable support for specific FHIR versions:
178//! - `R4`: FHIR 4.0.1 support
179//! - `R4B`: FHIR 4.3.0 support
180//! - `R5`: FHIR 5.0.0 support
181//! - `R6`: FHIR 6.0.0 support
182
183pub mod app;
184pub mod canonical;
185pub mod compartment;
186pub mod constants;
187pub mod data_source;
188mod error;
189pub mod fhir_format;
190mod handlers;
191/// Structural + FHIRPath-syntax linting for ViewDefinition documents (#753)
192/// - see [`lint::lint_view_definition`].
193pub mod lint;
194mod models;
195pub mod params;
196pub mod parquet_schema;
197mod parquet_zip;
198pub mod reference_collector;
199pub mod remote_fetch;
200pub mod remote_resolver;
201pub mod sqlquery;
202pub mod traits;
203
204pub use compartment::{resolve_group_members_to_patient_refs, resource_in_patient_compartment};
205pub use constants::{ConstantValue, parse_constant_from_json};
206pub use params::{
207    ExtractedRunParams, body_has_subject, extract_run_params_from_json, split_csv_refs,
208};
209pub use remote_fetch::{RemoteResolver, prefetch_external_resources};
210pub use remote_resolver::{
211    AllowedBaseUrl, DenyReason, FetchDecision, RemoteResolveConfig, is_blocked_address,
212    is_disallowed_ip, parse_allowed_base_urls,
213};
214pub use sqlquery::{
215    BINDABLE_PARAMETER_TYPES, BoundParam, ColumnFhirType, DependsOnView, InMemorySqlEngine,
216    LibraryParameter, Placeholder, QueryResult, ScanError, ScanResult, SourcePosition,
217    SqlQueryError, SqlQueryLibrary, SqlQueryRunParams, TableRef, TableSchema, bind_supplied_params,
218    extract_sqlquery_params_from_json, format_fhir_parameters, parse_sqlquery_library, scan_sql,
219    undeclared_tables,
220};
221
222use chrono::{DateTime, Utc};
223use helios_fhirpath::{EvaluationContext, EvaluationResult, evaluate_expression};
224use rayon::prelude::*;
225use serde::{Deserialize, Serialize};
226use std::collections::HashMap;
227use std::io::{BufRead, Write};
228use thiserror::Error;
229use traits::*;
230
231// Re-export commonly used types and traits for easier access
232pub use helios_fhir::FhirVersion;
233pub use traits::{BundleTrait, ResourceTrait, ViewDefinitionTrait};
234
235/// Multi-version ViewDefinition container supporting version-agnostic operations.
236///
237/// This enum provides a unified interface for working with ViewDefinition resources
238/// across different FHIR specification versions. It enables applications to handle
239/// multiple FHIR versions simultaneously while maintaining type safety.
240///
241/// # Supported Versions
242///
243/// - **R4**: FHIR 4.0.1 ViewDefinition (normative)
244/// - **R4B**: FHIR 4.3.0 ViewDefinition (ballot)
245/// - **R5**: FHIR 5.0.0 ViewDefinition (ballot)
246/// - **R6**: FHIR 6.0.0 ViewDefinition (draft)
247///
248/// # Examples
249///
250/// ```rust
251/// use helios_sof::{SofViewDefinition, ContentType};
252/// # #[cfg(feature = "R4")]
253/// use helios_fhir::r4::ViewDefinition;
254///
255/// # #[cfg(feature = "R4")]
256/// # {
257/// // Parse from JSON
258/// let json = r#"{
259///     "resourceType": "ViewDefinition",
260///     "resource": "Patient",
261///     "select": [{
262///         "column": [{
263///             "name": "id",
264///             "path": "id"
265///         }]
266///     }]
267/// }"#;
268///
269/// let view_def: ViewDefinition = serde_json::from_str(json)?;
270/// let sof_view = SofViewDefinition::R4(view_def);
271///
272/// // Check version
273/// assert_eq!(sof_view.version(), helios_fhir::FhirVersion::R4);
274/// # }
275/// # Ok::<(), Box<dyn std::error::Error>>(())
276/// ```
277#[derive(Debug, Clone)]
278pub enum SofViewDefinition {
279    #[cfg(feature = "R4")]
280    R4(helios_fhir::r4::ViewDefinition),
281    #[cfg(feature = "R4B")]
282    R4B(helios_fhir::r4b::ViewDefinition),
283    #[cfg(feature = "R5")]
284    R5(helios_fhir::r5::ViewDefinition),
285    #[cfg(feature = "R6")]
286    R6(helios_fhir::r6::ViewDefinition),
287}
288
289impl SofViewDefinition {
290    /// Returns the FHIR specification version of this ViewDefinition.
291    ///
292    /// This method provides version detection for multi-version applications,
293    /// enabling version-specific processing logic and compatibility checks.
294    ///
295    /// # Returns
296    ///
297    /// The `FhirVersion` enum variant corresponding to this ViewDefinition's specification.
298    ///
299    /// # Examples
300    ///
301    /// ```rust
302    /// use helios_sof::SofViewDefinition;
303    /// use helios_fhir::FhirVersion;
304    ///
305    /// # #[cfg(feature = "R5")]
306    /// # {
307    /// # let view_def = helios_fhir::r5::ViewDefinition::default();
308    /// let sof_view = SofViewDefinition::R5(view_def);
309    /// assert_eq!(sof_view.version(), helios_fhir::FhirVersion::R5);
310    /// # }
311    /// ```
312    pub fn version(&self) -> helios_fhir::FhirVersion {
313        match self {
314            #[cfg(feature = "R4")]
315            SofViewDefinition::R4(_) => helios_fhir::FhirVersion::R4,
316            #[cfg(feature = "R4B")]
317            SofViewDefinition::R4B(_) => helios_fhir::FhirVersion::R4B,
318            #[cfg(feature = "R5")]
319            SofViewDefinition::R5(_) => helios_fhir::FhirVersion::R5,
320            #[cfg(feature = "R6")]
321            SofViewDefinition::R6(_) => helios_fhir::FhirVersion::R6,
322        }
323    }
324}
325
326/// Multi-version Bundle container supporting version-agnostic operations.
327///
328/// This enum provides a unified interface for working with FHIR Bundle resources
329/// across different FHIR specification versions. Bundles contain the actual FHIR
330/// resources that will be processed by ViewDefinitions.
331///
332/// # Supported Versions
333///
334/// - **R4**: FHIR 4.0.1 Bundle (normative)
335/// - **R4B**: FHIR 4.3.0 Bundle (ballot)
336/// - **R5**: FHIR 5.0.0 Bundle (ballot)
337/// - **R6**: FHIR 6.0.0 Bundle (draft)
338///
339/// # Examples
340///
341/// ```rust
342/// # #[cfg(not(target_os = "windows"))]
343/// # {
344/// use helios_sof::SofBundle;
345/// # #[cfg(feature = "R4")]
346/// use helios_fhir::r4::Bundle;
347///
348/// # #[cfg(feature = "R4")]
349/// # {
350/// // Parse from JSON
351/// let json = r#"{
352///     "resourceType": "Bundle",
353///     "type": "collection",
354///     "entry": [{
355///         "resource": {
356///             "resourceType": "Patient",
357///             "id": "example"
358///         }
359///     }]
360/// }"#;
361///
362/// let bundle: Bundle = serde_json::from_str(json)?;
363/// let sof_bundle = SofBundle::R4(bundle);
364///
365/// // Check version compatibility
366/// assert_eq!(sof_bundle.version(), helios_fhir::FhirVersion::R4);
367/// # }
368/// # }
369/// # Ok::<(), Box<dyn std::error::Error>>(())
370/// ```
371#[derive(Debug, Clone)]
372pub enum SofBundle {
373    #[cfg(feature = "R4")]
374    R4(helios_fhir::r4::Bundle),
375    #[cfg(feature = "R4B")]
376    R4B(helios_fhir::r4b::Bundle),
377    #[cfg(feature = "R5")]
378    R5(helios_fhir::r5::Bundle),
379    #[cfg(feature = "R6")]
380    R6(helios_fhir::r6::Bundle),
381}
382
383impl SofBundle {
384    /// Returns the FHIR specification version of this Bundle.
385    ///
386    /// This method provides version detection for multi-version applications,
387    /// ensuring that ViewDefinitions and Bundles use compatible FHIR versions.
388    ///
389    /// # Returns
390    ///
391    /// The `FhirVersion` enum variant corresponding to this Bundle's specification.
392    ///
393    /// # Examples
394    ///
395    /// ```rust
396    /// use helios_sof::SofBundle;
397    /// use helios_fhir::FhirVersion;
398    ///
399    /// # #[cfg(feature = "R4")]
400    /// # {
401    /// # let bundle = helios_fhir::r4::Bundle::default();
402    /// let sof_bundle = SofBundle::R4(bundle);
403    /// assert_eq!(sof_bundle.version(), helios_fhir::FhirVersion::R4);
404    /// # }
405    /// ```
406    pub fn version(&self) -> helios_fhir::FhirVersion {
407        match self {
408            #[cfg(feature = "R4")]
409            SofBundle::R4(_) => helios_fhir::FhirVersion::R4,
410            #[cfg(feature = "R4B")]
411            SofBundle::R4B(_) => helios_fhir::FhirVersion::R4B,
412            #[cfg(feature = "R5")]
413            SofBundle::R5(_) => helios_fhir::FhirVersion::R5,
414            #[cfg(feature = "R6")]
415            SofBundle::R6(_) => helios_fhir::FhirVersion::R6,
416        }
417    }
418}
419
420/// Multi-version CapabilityStatement container supporting version-agnostic operations.
421///
422/// This enum provides a unified interface for working with CapabilityStatement resources
423/// across different FHIR specification versions. It enables applications to handle
424/// multiple FHIR versions simultaneously while maintaining type safety.
425///
426/// # Supported Versions
427///
428/// - **R4**: FHIR 4.0.1 CapabilityStatement (normative)
429/// - **R4B**: FHIR 4.3.0 CapabilityStatement (ballot)
430/// - **R5**: FHIR 5.0.0 CapabilityStatement (ballot)
431/// - **R6**: FHIR 6.0.0 CapabilityStatement (draft)
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(untagged)]
434pub enum SofCapabilityStatement {
435    #[cfg(feature = "R4")]
436    R4(helios_fhir::r4::CapabilityStatement),
437    #[cfg(feature = "R4B")]
438    R4B(helios_fhir::r4b::CapabilityStatement),
439    #[cfg(feature = "R5")]
440    R5(helios_fhir::r5::CapabilityStatement),
441    #[cfg(feature = "R6")]
442    R6(helios_fhir::r6::CapabilityStatement),
443}
444
445impl SofCapabilityStatement {
446    /// Returns the FHIR specification version of this CapabilityStatement.
447    pub fn version(&self) -> helios_fhir::FhirVersion {
448        match self {
449            #[cfg(feature = "R4")]
450            SofCapabilityStatement::R4(_) => helios_fhir::FhirVersion::R4,
451            #[cfg(feature = "R4B")]
452            SofCapabilityStatement::R4B(_) => helios_fhir::FhirVersion::R4B,
453            #[cfg(feature = "R5")]
454            SofCapabilityStatement::R5(_) => helios_fhir::FhirVersion::R5,
455            #[cfg(feature = "R6")]
456            SofCapabilityStatement::R6(_) => helios_fhir::FhirVersion::R6,
457        }
458    }
459}
460
461/// Type alias for the version-independent Parameters container.
462///
463/// This alias provides backward compatibility while using the unified
464/// VersionIndependentParameters from the helios_fhir crate.
465pub type SofParameters = helios_fhir::VersionIndependentParameters;
466
467/// Comprehensive error type for SQL-on-FHIR operations.
468///
469/// This enum covers all possible error conditions that can occur during
470/// ViewDefinition processing, from validation failures to output formatting issues.
471/// Each variant provides specific context about the error to aid in debugging.
472///
473/// # Error Categories
474///
475/// - **Validation**: ViewDefinition structure and logic validation
476/// - **Evaluation**: FHIRPath expression evaluation failures
477/// - **I/O**: File and serialization operations
478/// - **Format**: Output format conversion issues
479///
480/// # Examples
481///
482/// ```rust,no_run
483/// use helios_sof::{SofError, SofViewDefinition, SofBundle, ContentType, run_view_definition};
484///
485/// # let view = SofViewDefinition::R4(helios_fhir::r4::ViewDefinition::default());
486/// # let bundle = SofBundle::R4(helios_fhir::r4::Bundle::default());
487/// # let content_type = ContentType::Json;
488/// match run_view_definition(view, bundle, content_type) {
489///     Ok(output) => {
490///         println!("Transformation successful");
491///     },
492///     Err(SofError::InvalidViewDefinition(msg)) => {
493///         eprintln!("ViewDefinition validation failed: {}", msg);
494///     },
495///     Err(SofError::FhirPathError(msg)) => {
496///         eprintln!("FHIRPath evaluation error: {}", msg);
497///     },
498///     Err(SofError::UnsupportedContentType(format)) => {
499///         eprintln!("Unsupported output format: {}", format);
500///     },
501///     Err(e) => {
502///         eprintln!("Other error: {}", e);
503///     }
504/// }
505/// ```
506#[derive(Debug, Error)]
507pub enum SofError {
508    /// ViewDefinition structure or logic validation failed.
509    ///
510    /// This error occurs when a ViewDefinition contains invalid or inconsistent
511    /// configuration, such as missing required fields, invalid FHIRPath expressions,
512    /// or incompatible select/unionAll structures.
513    #[error("Invalid ViewDefinition: {0}")]
514    InvalidViewDefinition(String),
515
516    /// FHIRPath expression evaluation failed.
517    ///
518    /// This error occurs when a FHIRPath expression in a ViewDefinition cannot
519    /// be evaluated, either due to syntax errors or runtime evaluation issues.
520    #[error("FHIRPath evaluation error: {0}")]
521    FhirPathError(String),
522
523    /// JSON serialization/deserialization failed.
524    ///
525    /// This error occurs when parsing input JSON or serializing output data fails,
526    /// typically due to malformed JSON or incompatible data structures.
527    #[error("Serialization error: {0}")]
528    SerializationError(#[from] serde_json::Error),
529
530    /// CSV processing failed.
531    ///
532    /// This error occurs during CSV output generation, such as when writing
533    /// headers or data rows to the CSV format.
534    #[error("CSV error: {0}")]
535    CsvError(#[from] csv::Error),
536
537    /// File I/O operation failed.
538    ///
539    /// This error occurs when reading input files or writing output files fails,
540    /// typically due to permission issues or missing files.
541    #[error("IO error: {0}")]
542    IoError(#[from] std::io::Error),
543
544    /// Unsupported output content type requested.
545    ///
546    /// This error occurs when an invalid or unimplemented content type is
547    /// specified for output formatting.
548    #[error("Unsupported content type: {0}")]
549    UnsupportedContentType(String),
550
551    /// CSV writer internal error.
552    ///
553    /// This error occurs when the CSV writer encounters an internal issue
554    /// that prevents successful output generation.
555    #[error("CSV writer error: {0}")]
556    CsvWriterError(String),
557
558    /// Arrow conversion or IPC serialization error.
559    ///
560    /// This error occurs when building Arrow record batches or writing the
561    /// Arrow IPC stream fails.
562    #[error("Arrow conversion error: {0}")]
563    ArrowConversionError(String),
564
565    /// Invalid source parameter value.
566    ///
567    /// This error occurs when the source parameter contains an invalid URL or path.
568    #[error("Invalid source: {0}")]
569    InvalidSource(String),
570
571    /// Source not found.
572    ///
573    /// This error occurs when the specified source file or URL cannot be found.
574    #[error("Source not found: {0}")]
575    SourceNotFound(String),
576
577    /// Failed to fetch data from source.
578    ///
579    /// This error occurs when fetching data from a remote source fails.
580    #[error("Failed to fetch source: {0}")]
581    SourceFetchError(String),
582
583    /// Failed to read source data.
584    ///
585    /// This error occurs when reading data from the source fails.
586    #[error("Failed to read source: {0}")]
587    SourceReadError(String),
588
589    /// Invalid content in source.
590    ///
591    /// This error occurs when the source content is not valid FHIR data.
592    #[error("Invalid source content: {0}")]
593    InvalidSourceContent(String),
594
595    /// Unsupported source protocol.
596    ///
597    /// This error occurs when the source URL uses an unsupported protocol.
598    #[error("Unsupported source protocol: {0}")]
599    UnsupportedSourceProtocol(String),
600
601    /// Parquet conversion error.
602    ///
603    /// This error occurs when converting data to Parquet format fails.
604    #[error("Parquet conversion error: {0}")]
605    ParquetConversionError(String),
606
607    /// A `patient` / `group` reference supplied to `$viewdefinition-run` does
608    /// not resolve against the supplied resources. Per the SoF v2 spec, this
609    /// is a `400 Bad Request` (mapped to OperationOutcome `not-found` /
610    /// `invalid`), not a silent empty-result.
611    #[error("Referenced resource not found: {0}")]
612    ReferencedResourceNotFound(String),
613}
614
615/// Supported output content types for ViewDefinition transformations.
616///
617/// This enum defines the available output formats for transformed FHIR data.
618/// Each format has specific characteristics and use cases for different
619/// integration scenarios.
620///
621/// # Format Descriptions
622///
623/// - **CSV**: Comma-separated values without headers
624/// - **CSV with Headers**: Comma-separated values with column headers
625/// - **JSON**: Pretty-printed JSON array of objects
626/// - **NDJSON**: Newline-delimited JSON (one object per line)
627/// - **Parquet**: Apache Parquet columnar format (planned)
628///
629/// # Examples
630///
631/// ```rust
632/// use helios_sof::ContentType;
633///
634/// // Parse from string
635/// let csv_type = ContentType::from_string("text/csv")?;
636/// assert_eq!(csv_type, ContentType::CsvWithHeader);  // Default includes headers
637///
638/// let json_type = ContentType::from_string("application/json")?;
639/// assert_eq!(json_type, ContentType::Json);
640///
641/// // CSV without headers
642/// let csv_no_headers = ContentType::from_string("text/csv;header=false")?;
643/// assert_eq!(csv_no_headers, ContentType::Csv);
644/// # Ok::<(), helios_sof::SofError>(())
645/// ```
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647pub enum ContentType {
648    /// Comma-separated values format without headers
649    Csv,
650    /// Comma-separated values format with column headers
651    CsvWithHeader,
652    /// Pretty-printed JSON array format
653    Json,
654    /// Newline-delimited JSON format (NDJSON)
655    NdJson,
656    /// Apache Parquet columnar format (not yet implemented)
657    Parquet,
658    /// Apache Arrow IPC stream format — the same record batches the Parquet
659    /// path builds, encoded for zero-parse consumption of live query results
660    ArrowIpc,
661}
662
663impl ContentType {
664    /// Parse a content type from its MIME type string representation.
665    ///
666    /// This method converts standard MIME type strings to the corresponding
667    /// ContentType enum variants. It supports the SQL-on-FHIR specification's
668    /// recommended content types.
669    ///
670    /// # Supported MIME Types
671    ///
672    /// - `"text/csv"` → [`ContentType::Csv`]
673    /// - `"text/csv"` → [`ContentType::CsvWithHeader`] (default: headers included)
674    /// - `"text/csv;header=true"` → [`ContentType::CsvWithHeader`]
675    /// - `"text/csv;header=false"` → [`ContentType::Csv`]
676    /// - `"application/json"` → [`ContentType::Json`]
677    /// - `"application/ndjson"` → [`ContentType::NdJson`]
678    /// - `"application/x-ndjson"` → [`ContentType::NdJson`]
679    /// - `"application/vnd.apache.parquet"` → [`ContentType::Parquet`] (spec native media type)
680    /// - `"application/octet-stream"` → [`ContentType::Parquet`] (spec Accept-table value)
681    /// - `"application/parquet"` → [`ContentType::Parquet`] (permissive alias)
682    ///
683    /// # Arguments
684    ///
685    /// * `s` - The MIME type string to parse
686    ///
687    /// # Returns
688    ///
689    /// * `Ok(ContentType)` - Successfully parsed content type
690    /// * `Err(SofError::UnsupportedContentType)` - Unknown or unsupported MIME type
691    ///
692    /// # Examples
693    ///
694    /// ```rust
695    /// use helios_sof::ContentType;
696    ///
697    /// // Shortened format names
698    /// let csv = ContentType::from_string("csv")?;
699    /// assert_eq!(csv, ContentType::CsvWithHeader);
700    ///
701    /// let json = ContentType::from_string("json")?;
702    /// assert_eq!(json, ContentType::Json);
703    ///
704    /// let ndjson = ContentType::from_string("ndjson")?;
705    /// assert_eq!(ndjson, ContentType::NdJson);
706    ///
707    /// // Full MIME types still supported
708    /// let csv_mime = ContentType::from_string("text/csv")?;
709    /// assert_eq!(csv_mime, ContentType::CsvWithHeader);
710    ///
711    /// // CSV with headers explicitly
712    /// let csv_headers = ContentType::from_string("text/csv;header=true")?;
713    /// assert_eq!(csv_headers, ContentType::CsvWithHeader);
714    ///
715    /// // CSV without headers
716    /// let csv_no_headers = ContentType::from_string("text/csv;header=false")?;
717    /// assert_eq!(csv_no_headers, ContentType::Csv);
718    ///
719    /// // JSON format
720    /// let json_mime = ContentType::from_string("application/json")?;
721    /// assert_eq!(json_mime, ContentType::Json);
722    ///
723    /// // Error for unsupported type
724    /// assert!(ContentType::from_string("text/plain").is_err());
725    /// # Ok::<(), helios_sof::SofError>(())
726    /// ```
727    pub fn from_string(s: &str) -> Result<Self, SofError> {
728        match s {
729            // Shortened format names
730            "csv" => Ok(ContentType::CsvWithHeader),
731            "json" => Ok(ContentType::Json),
732            "ndjson" => Ok(ContentType::NdJson),
733            "parquet" => Ok(ContentType::Parquet),
734            "arrow" => Ok(ContentType::ArrowIpc),
735            // Full MIME types (for Accept header compatibility)
736            "text/csv;header=false" => Ok(ContentType::Csv),
737            "text/csv" | "text/csv;header=true" => Ok(ContentType::CsvWithHeader),
738            "application/json" => Ok(ContentType::Json),
739            "application/ndjson" | "application/x-ndjson" => Ok(ContentType::NdJson),
740            // `application/vnd.apache.parquet` is the format's native media
741            // type per the spec's Common Operation Behavior table;
742            // `application/octet-stream` is the spec Accept-table value
743            // (audit item #8) and `application/parquet` is kept as a
744            // permissive alias for backwards-compat with clients that
745            // still send it.
746            "application/vnd.apache.parquet"
747            | "application/octet-stream"
748            | "application/parquet" => Ok(ContentType::Parquet),
749            "application/vnd.apache.arrow.stream" => Ok(ContentType::ArrowIpc),
750            _ => Err(SofError::UnsupportedContentType(s.to_string())),
751        }
752    }
753
754    /// The format's native media type per the SoF v2 spec's Common Operation
755    /// Behavior output-format table. This is the `Content-Type` served for
756    /// the raw-payload (default) representation.
757    pub fn mime_type(&self) -> &'static str {
758        match self {
759            ContentType::Csv | ContentType::CsvWithHeader => "text/csv",
760            ContentType::Json => "application/json",
761            ContentType::NdJson => "application/x-ndjson",
762            ContentType::Parquet => "application/vnd.apache.parquet",
763            ContentType::ArrowIpc => "application/vnd.apache.arrow.stream",
764        }
765    }
766}
767
768/// Returns the FHIR version string for the newest enabled version.
769///
770/// This function provides the version string that should be used in CapabilityStatements
771/// and other FHIR resources that need to specify their version.
772pub fn get_fhir_version_string() -> &'static str {
773    let newest_version = get_newest_enabled_fhir_version();
774
775    match newest_version {
776        #[cfg(feature = "R4")]
777        helios_fhir::FhirVersion::R4 => "4.0.1",
778        #[cfg(feature = "R4B")]
779        helios_fhir::FhirVersion::R4B => "4.3.0",
780        #[cfg(feature = "R5")]
781        helios_fhir::FhirVersion::R5 => "5.0.0",
782        #[cfg(feature = "R6")]
783        helios_fhir::FhirVersion::R6 => "6.0.0",
784        // A `FhirVersion` variant can exist without helios-sof enabling the
785        // matching feature: another crate in the build graph (e.g. helios-audit,
786        // whose R5/R6 features alias to helios-fhir/R4B for the BALP baseline)
787        // can turn on a helios-fhir version feature that helios-sof did not.
788        // `newest_version` always comes from helios-sof's own features, so this
789        // arm is genuinely unreachable at runtime.
790        #[allow(unreachable_patterns)]
791        _ => unreachable!(
792            "get_newest_enabled_fhir_version only returns versions enabled for helios-sof"
793        ),
794    }
795}
796
797/// Returns the newest FHIR version that is enabled at compile time.
798///
799/// This function uses compile-time feature detection to determine which FHIR
800/// version should be used when multiple versions are enabled. The priority order
801/// is: R6 > R5 > R4B > R4, where newer versions take precedence.
802///
803/// # Examples
804///
805/// ```rust
806/// use helios_sof::{get_newest_enabled_fhir_version, FhirVersion};
807///
808/// # #[cfg(any(feature = "R4", feature = "R4B", feature = "R5", feature = "R6"))]
809/// # {
810/// let version = get_newest_enabled_fhir_version();
811/// // If R5 and R4 are both enabled, this returns R5
812/// # }
813/// ```
814///
815/// # Panics
816///
817/// This function will panic at compile time if no FHIR version features are enabled.
818pub fn get_newest_enabled_fhir_version() -> helios_fhir::FhirVersion {
819    #[cfg(feature = "R6")]
820    return helios_fhir::FhirVersion::R6;
821
822    #[cfg(all(feature = "R5", not(feature = "R6")))]
823    return helios_fhir::FhirVersion::R5;
824
825    #[cfg(all(feature = "R4B", not(feature = "R5"), not(feature = "R6")))]
826    return helios_fhir::FhirVersion::R4B;
827
828    #[cfg(all(
829        feature = "R4",
830        not(feature = "R4B"),
831        not(feature = "R5"),
832        not(feature = "R6")
833    ))]
834    return helios_fhir::FhirVersion::R4;
835
836    #[cfg(not(any(feature = "R4", feature = "R4B", feature = "R5", feature = "R6")))]
837    panic!("At least one FHIR version feature must be enabled");
838}
839
840/// A single row of processed tabular data from ViewDefinition transformation.
841///
842/// This struct represents one row in the output table, containing values for
843/// each column defined in the ViewDefinition. Values are stored as optional
844/// JSON values to handle nullable fields and diverse FHIR data types.
845///
846/// # Structure
847///
848/// Each `ProcessedRow` contains a vector of optional JSON values, where:
849/// - `Some(value)` represents a non-null column value
850/// - `None` represents a null/missing column value
851/// - The order matches the column order in [`ProcessedResult::columns`]
852///
853/// # Examples
854///
855/// ```rust
856/// use helios_sof::ProcessedRow;
857/// use serde_json::Value;
858///
859/// let row = ProcessedRow {
860///     values: vec![
861///         Some(Value::String("patient-123".to_string())),
862///         Some(Value::String("Doe".to_string())),
863///         None, // Missing birth date
864///         Some(Value::Bool(true)),
865///     ]
866/// };
867/// ```
868#[derive(Debug, Clone, Serialize, Deserialize)]
869pub struct ProcessedRow {
870    /// Column values for this row, ordered according to ProcessedResult::columns
871    pub values: Vec<Option<serde_json::Value>>,
872}
873
874/// Complete result of ViewDefinition transformation containing columns and data rows.
875///
876/// This struct represents the tabular output from processing a ViewDefinition
877/// against a Bundle of FHIR resources. It contains both the column definitions
878/// and the actual data rows in a format ready for serialization to various
879/// output formats.
880///
881/// # Structure
882///
883/// - [`columns`](Self::columns): Ordered list of column names from the ViewDefinition
884/// - [`rows`](Self::rows): Data rows where each row contains values in column order
885///
886/// # Examples
887///
888/// ```rust
889/// use helios_sof::{ProcessedResult, ProcessedRow};
890/// use serde_json::Value;
891///
892/// let result = ProcessedResult {
893///     columns: vec![
894///         "patient_id".to_string(),
895///         "family_name".to_string(),
896///         "given_name".to_string(),
897///     ],
898///     rows: vec![
899///         ProcessedRow {
900///             values: vec![
901///                 Some(Value::String("patient-1".to_string())),
902///                 Some(Value::String("Smith".to_string())),
903///                 Some(Value::String("John".to_string())),
904///             ]
905///         },
906///         ProcessedRow {
907///             values: vec![
908///                 Some(Value::String("patient-2".to_string())),
909///                 Some(Value::String("Doe".to_string())),
910///                 None, // Missing given name
911///             ]
912///         },
913///     ]
914/// };
915///
916/// assert_eq!(result.columns.len(), 3);
917/// assert_eq!(result.rows.len(), 2);
918/// ```
919#[derive(Debug, Clone, Serialize, Deserialize)]
920pub struct ProcessedResult {
921    /// Ordered list of column names as defined in the ViewDefinition
922    pub columns: Vec<String>,
923    /// Data rows containing values for each column
924    pub rows: Vec<ProcessedRow>,
925}
926
927/// Execute a SQL-on-FHIR ViewDefinition transformation on a FHIR Bundle.
928///
929/// This is the main entry point for SQL-on-FHIR transformations. It processes
930/// a ViewDefinition against a Bundle of FHIR resources and produces output in
931/// the specified format. The function handles version compatibility, validation,
932/// FHIRPath evaluation, and output formatting.
933///
934/// # Arguments
935///
936/// * `view_definition` - The ViewDefinition containing transformation logic
937/// * `bundle` - The Bundle containing FHIR resources to process
938/// * `content_type` - The desired output format
939///
940/// # Returns
941///
942/// * `Ok(Vec<u8>)` - Formatted output bytes ready for writing to file or stdout
943/// * `Err(SofError)` - Detailed error information about what went wrong
944///
945/// # Validation
946///
947/// The function performs comprehensive validation:
948/// - FHIR version compatibility between ViewDefinition and Bundle
949/// - ViewDefinition structure and logic validation
950/// - FHIRPath expression syntax and evaluation
951/// - Output format compatibility
952///
953/// # Examples
954///
955/// ```rust
956/// use helios_sof::{SofViewDefinition, SofBundle, ContentType, run_view_definition};
957///
958/// # #[cfg(feature = "R4")]
959/// # {
960/// // Create a simple ViewDefinition
961/// let view_json = serde_json::json!({
962///     "resourceType": "ViewDefinition",
963///     "status": "active",
964///     "resource": "Patient",
965///     "select": [{
966///         "column": [{
967///             "name": "id",
968///             "path": "id"
969///         }]
970///     }]
971/// });
972/// let view_def: helios_fhir::r4::ViewDefinition = serde_json::from_value(view_json)?;
973///
974/// // Create a simple Bundle
975/// let bundle_json = serde_json::json!({
976///     "resourceType": "Bundle",
977///     "type": "collection",
978///     "entry": []
979/// });
980/// let bundle: helios_fhir::r4::Bundle = serde_json::from_value(bundle_json)?;
981///
982/// let sof_view = SofViewDefinition::R4(view_def);
983/// let sof_bundle = SofBundle::R4(bundle);
984///
985/// // Generate CSV with headers
986/// let csv_output = run_view_definition(
987///     sof_view,
988///     sof_bundle,
989///     ContentType::CsvWithHeader
990/// )?;
991///
992/// // Write to file or stdout
993/// std::fs::write("output.csv", csv_output)?;
994/// # }
995/// # Ok::<(), Box<dyn std::error::Error>>(())
996/// ```
997///
998/// # Error Handling
999///
1000/// Common error scenarios:
1001///
1002/// ```rust,no_run
1003/// use helios_sof::{SofError, SofViewDefinition, SofBundle, ContentType, run_view_definition};
1004///
1005/// # let view = SofViewDefinition::R4(helios_fhir::r4::ViewDefinition::default());
1006/// # let bundle = SofBundle::R4(helios_fhir::r4::Bundle::default());
1007/// # let content_type = ContentType::Json;
1008/// match run_view_definition(view, bundle, content_type) {
1009///     Ok(output) => {
1010///         println!("Success: {} bytes generated", output.len());
1011///     },
1012///     Err(SofError::InvalidViewDefinition(msg)) => {
1013///         eprintln!("ViewDefinition error: {}", msg);
1014///     },
1015///     Err(SofError::FhirPathError(msg)) => {
1016///         eprintln!("FHIRPath error: {}", msg);
1017///     },
1018///     Err(e) => {
1019///         eprintln!("Other error: {}", e);
1020///     }
1021/// }
1022/// ```
1023pub fn run_view_definition(
1024    view_definition: SofViewDefinition,
1025    bundle: SofBundle,
1026    content_type: ContentType,
1027) -> Result<Vec<u8>, SofError> {
1028    run_view_definition_with_options(view_definition, bundle, content_type, RunOptions::default())
1029}
1030
1031/// Parses a JSON value into a [`SofViewDefinition`] using the newest enabled
1032/// FHIR version.
1033///
1034/// Use [`parse_view_definition_for_version`] to pick a specific version (for
1035/// example when matching the FHIR version of an inline `Bundle` parameter).
1036pub fn parse_view_definition(json: serde_json::Value) -> Result<SofViewDefinition, SofError> {
1037    parse_view_definition_for_version(json, get_newest_enabled_fhir_version())
1038}
1039
1040/// Parses a JSON value into a [`SofViewDefinition`] using the specified FHIR
1041/// version.
1042pub fn parse_view_definition_for_version(
1043    json: serde_json::Value,
1044    version: helios_fhir::FhirVersion,
1045) -> Result<SofViewDefinition, SofError> {
1046    match version {
1047        #[cfg(feature = "R4")]
1048        helios_fhir::FhirVersion::R4 => {
1049            let view_def: helios_fhir::r4::ViewDefinition =
1050                serde_json::from_value(json).map_err(|e| {
1051                    SofError::InvalidViewDefinition(format!("Invalid R4 ViewDefinition: {}", e))
1052                })?;
1053            Ok(SofViewDefinition::R4(view_def))
1054        }
1055        #[cfg(feature = "R4B")]
1056        helios_fhir::FhirVersion::R4B => {
1057            let view_def: helios_fhir::r4b::ViewDefinition =
1058                serde_json::from_value(json).map_err(|e| {
1059                    SofError::InvalidViewDefinition(format!("Invalid R4B ViewDefinition: {}", e))
1060                })?;
1061            Ok(SofViewDefinition::R4B(view_def))
1062        }
1063        #[cfg(feature = "R5")]
1064        helios_fhir::FhirVersion::R5 => {
1065            let view_def: helios_fhir::r5::ViewDefinition =
1066                serde_json::from_value(json).map_err(|e| {
1067                    SofError::InvalidViewDefinition(format!("Invalid R5 ViewDefinition: {}", e))
1068                })?;
1069            Ok(SofViewDefinition::R5(view_def))
1070        }
1071        #[cfg(feature = "R6")]
1072        helios_fhir::FhirVersion::R6 => {
1073            let view_def: helios_fhir::r6::ViewDefinition =
1074                serde_json::from_value(json).map_err(|e| {
1075                    SofError::InvalidViewDefinition(format!("Invalid R6 ViewDefinition: {}", e))
1076                })?;
1077            Ok(SofViewDefinition::R6(view_def))
1078        }
1079        // The requested `FhirVersion` variant may exist (because another crate
1080        // enabled a helios-fhir version feature) while helios-sof was not built
1081        // with support for it. Report that rather than failing to compile.
1082        #[allow(unreachable_patterns)]
1083        _ => Err(SofError::InvalidViewDefinition(format!(
1084            "helios-sof was not compiled with support for FHIR version {:?}",
1085            version
1086        ))),
1087    }
1088}
1089
1090/// Wraps a list of raw FHIR resources in a `collection` Bundle of the newest
1091/// enabled FHIR version.
1092pub fn create_bundle_from_resources(
1093    resources: Vec<serde_json::Value>,
1094) -> Result<SofBundle, SofError> {
1095    create_bundle_from_resources_for_version(resources, get_newest_enabled_fhir_version())
1096}
1097
1098/// Wraps a list of raw FHIR resources in a `collection` Bundle of the
1099/// specified FHIR version.
1100pub fn create_bundle_from_resources_for_version(
1101    resources: Vec<serde_json::Value>,
1102    version: helios_fhir::FhirVersion,
1103) -> Result<SofBundle, SofError> {
1104    let bundle_json = serde_json::json!({
1105        "resourceType": "Bundle",
1106        "type": "collection",
1107        "entry": resources.into_iter().map(|resource| {
1108            serde_json::json!({ "resource": resource })
1109        }).collect::<Vec<_>>()
1110    });
1111
1112    match version {
1113        #[cfg(feature = "R4")]
1114        helios_fhir::FhirVersion::R4 => {
1115            let bundle: helios_fhir::r4::Bundle =
1116                serde_json::from_value(bundle_json).map_err(|e| {
1117                    SofError::InvalidViewDefinition(format!("Failed to create R4 Bundle: {}", e))
1118                })?;
1119            Ok(SofBundle::R4(bundle))
1120        }
1121        #[cfg(feature = "R4B")]
1122        helios_fhir::FhirVersion::R4B => {
1123            let bundle: helios_fhir::r4b::Bundle =
1124                serde_json::from_value(bundle_json).map_err(|e| {
1125                    SofError::InvalidViewDefinition(format!("Failed to create R4B Bundle: {}", e))
1126                })?;
1127            Ok(SofBundle::R4B(bundle))
1128        }
1129        #[cfg(feature = "R5")]
1130        helios_fhir::FhirVersion::R5 => {
1131            let bundle: helios_fhir::r5::Bundle =
1132                serde_json::from_value(bundle_json).map_err(|e| {
1133                    SofError::InvalidViewDefinition(format!("Failed to create R5 Bundle: {}", e))
1134                })?;
1135            Ok(SofBundle::R5(bundle))
1136        }
1137        #[cfg(feature = "R6")]
1138        helios_fhir::FhirVersion::R6 => {
1139            let bundle: helios_fhir::r6::Bundle =
1140                serde_json::from_value(bundle_json).map_err(|e| {
1141                    SofError::InvalidViewDefinition(format!("Failed to create R6 Bundle: {}", e))
1142                })?;
1143            Ok(SofBundle::R6(bundle))
1144        }
1145        // See the note in `parse_view_definition_for_version`: the variant can
1146        // exist without helios-sof having the matching feature enabled.
1147        #[allow(unreachable_patterns)]
1148        _ => Err(SofError::InvalidViewDefinition(format!(
1149            "helios-sof was not compiled with support for FHIR version {:?}",
1150            version
1151        ))),
1152    }
1153}
1154
1155/// Filters raw FHIR resource JSON by patient and/or group references using
1156/// the FHIR `CompartmentDefinition-patient` spec data.
1157///
1158/// Per the SQL-on-FHIR v2 `$viewdefinition-run` spec, `patient` is `0..1`
1159/// and `group` is `0..*`; both arguments accept slices and multiple values
1160/// are unioned. `group_refs` are resolved against any `Group` resources
1161/// found in `resources` (the `member.entity` Patient references contribute
1162/// to the effective patient-compartment set).
1163///
1164/// The compartment scan uses
1165/// `helios_fhir::compartment_expressions::{r4,r4b,r5,r6}::get_compartment_param_expressions`
1166/// — a compile-time join of `CompartmentDefinition-patient.json` against
1167/// `search-parameters.json` — to enumerate the spec-defined `(name,
1168/// FHIRPath-expression)` pairs that link a resource type to the `Patient`
1169/// compartment. Each expression is evaluated against the resource and the
1170/// resulting `Reference`(s) are matched against the requested patient set.
1171/// This replaces the prior hand-rolled `(subject|patient)` allowlist
1172/// (audit item #3) without any runtime data-file dependency.
1173///
1174/// **Absent-target handling (SoF v2 spec):** any `patient` / `group` reference
1175/// that does not resolve against the supplied resources is a hard error,
1176/// returned as [`SofError::ReferencedResourceNotFound`]. Callers surface this
1177/// as `400 Bad Request` + an `OperationOutcome` per the spec's error table.
1178/// Previously this path emitted a `Warning: 199` HTTP header and continued
1179/// with a (possibly empty) result; the warning-header behavior was
1180/// removed to align with the spec.
1181pub fn filter_resources_by_patient_and_group(
1182    resources: Vec<serde_json::Value>,
1183    patient_refs: &[String],
1184    group_refs: &[String],
1185    fhir_version: FhirVersion,
1186) -> Result<Vec<serde_json::Value>, SofError> {
1187    use std::collections::HashSet;
1188
1189    if patient_refs.is_empty() && group_refs.is_empty() {
1190        return Ok(resources);
1191    }
1192
1193    // Absent-target detection: any `patient` / `group` reference that
1194    // isn't represented by a resource in the supplied bundle is a hard
1195    // error per the SoF v2 spec error table.
1196    let mut absent: Vec<String> = Vec::new();
1197    for r in patient_refs {
1198        let canonical = if r.starts_with("Patient/") {
1199            r.clone()
1200        } else {
1201            format!("Patient/{}", r)
1202        };
1203        let id = canonical
1204            .strip_prefix("Patient/")
1205            .and_then(|s| s.split('/').next());
1206        let found = id
1207            .map(|id| {
1208                resources.iter().any(|res| {
1209                    res.get("resourceType").and_then(|v| v.as_str()) == Some("Patient")
1210                        && res.get("id").and_then(|v| v.as_str()) == Some(id)
1211                })
1212            })
1213            .unwrap_or(false);
1214        if !found {
1215            absent.push(canonical);
1216        }
1217    }
1218    for g in group_refs {
1219        let canonical = if g.starts_with("Group/") {
1220            g.clone()
1221        } else {
1222            format!("Group/{}", g)
1223        };
1224        let id = canonical
1225            .strip_prefix("Group/")
1226            .and_then(|s| s.split('/').next());
1227        let found = id
1228            .map(|id| {
1229                resources.iter().any(|res| {
1230                    res.get("resourceType").and_then(|v| v.as_str()) == Some("Group")
1231                        && res.get("id").and_then(|v| v.as_str()) == Some(id)
1232                })
1233            })
1234            .unwrap_or(false);
1235        if !found {
1236            absent.push(canonical);
1237        }
1238    }
1239    if !absent.is_empty() {
1240        return Err(SofError::ReferencedResourceNotFound(format!(
1241            "{} not found in supplied resources",
1242            absent.join(", ")
1243        )));
1244    }
1245
1246    // Build the effective patient-compartment set: explicit patient refs +
1247    // patient refs resolved from supplied groups. Both forms are
1248    // canonicalised to `Patient/{id}` so downstream comparisons don't
1249    // double-handle the prefix.
1250    let mut targets: HashSet<String> = patient_refs
1251        .iter()
1252        .map(|r| {
1253            if r.starts_with("Patient/") {
1254                r.clone()
1255            } else {
1256                format!("Patient/{}", r)
1257            }
1258        })
1259        .collect();
1260
1261    if !group_refs.is_empty() {
1262        targets.extend(compartment::resolve_group_members_to_patient_refs(
1263            group_refs, &resources,
1264        ));
1265    }
1266
1267    // No effective patient targets (e.g. supplied Group resolved to zero
1268    // Patient members). The targets themselves are present (they got past
1269    // the absent-target check above), so this is an empty-but-valid result.
1270    if targets.is_empty() {
1271        return Ok(Vec::new());
1272    }
1273
1274    let mut filtered = Vec::with_capacity(resources.len());
1275    for resource in resources.into_iter() {
1276        // Group resources are first-class compartment members when their
1277        // `Group/{id}` was requested directly (i.e. not via member
1278        // resolution). Skip the FHIRPath scan for Group itself.
1279        if resource.get("resourceType").and_then(|v| v.as_str()) == Some("Group")
1280            && resource
1281                .get("id")
1282                .and_then(|v| v.as_str())
1283                .map(|id| {
1284                    group_refs
1285                        .iter()
1286                        .any(|g| g == &format!("Group/{}", id) || g == id)
1287                })
1288                .unwrap_or(false)
1289        {
1290            filtered.push(resource);
1291            continue;
1292        }
1293
1294        if compartment::resource_in_patient_compartment(&resource, &targets, fhir_version)? {
1295            filtered.push(resource);
1296        }
1297    }
1298
1299    Ok(filtered)
1300}
1301
1302/// Filters raw FHIR resource JSON by their `meta.lastUpdated` timestamp,
1303/// returning only resources whose `lastUpdated` is strictly after `since`.
1304/// Resources without `meta.lastUpdated` are excluded.
1305pub fn filter_resources_by_since(
1306    resources: Vec<serde_json::Value>,
1307    since: DateTime<Utc>,
1308) -> Result<Vec<serde_json::Value>, SofError> {
1309    Ok(resources
1310        .into_iter()
1311        .filter(|resource| {
1312            resource
1313                .get("meta")
1314                .and_then(|m| m.get("lastUpdated"))
1315                .and_then(|lu| lu.as_str())
1316                .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
1317                .map(|t| t.with_timezone(&Utc) > since)
1318                .unwrap_or(false)
1319        })
1320        .collect())
1321}
1322
1323/// Configuration options for Parquet file generation.
1324#[derive(Debug, Clone)]
1325pub struct ParquetOptions {
1326    /// Target row group size in MB (64-1024)
1327    pub row_group_size_mb: u32,
1328    /// Target page size in KB (64-8192)
1329    pub page_size_kb: u32,
1330    /// Compression algorithm (none, snappy, gzip, lz4, brotli, zstd)
1331    pub compression: String,
1332    /// Maximum file size in MB (splits output when exceeded)
1333    pub max_file_size_mb: Option<u32>,
1334}
1335
1336impl Default for ParquetOptions {
1337    fn default() -> Self {
1338        Self {
1339            row_group_size_mb: 256,
1340            page_size_kb: 1024,
1341            compression: "snappy".to_string(),
1342            max_file_size_mb: None,
1343        }
1344    }
1345}
1346
1347/// Options for filtering and controlling ViewDefinition execution
1348#[derive(Debug, Clone, Default)]
1349pub struct RunOptions {
1350    /// Filter resources modified after this time
1351    pub since: Option<DateTime<Utc>>,
1352    /// Limit the number of results
1353    pub limit: Option<usize>,
1354    /// Page number for pagination (1-based)
1355    pub page: Option<usize>,
1356    /// Parquet-specific configuration options
1357    pub parquet_options: Option<ParquetOptions>,
1358}
1359
1360// =============================================================================
1361// Streaming/Chunked Processing Types
1362// =============================================================================
1363
1364/// Configuration for chunked NDJSON processing.
1365///
1366/// Controls how NDJSON files are read and processed in chunks to reduce
1367/// memory usage when handling large files.
1368///
1369/// # Examples
1370///
1371/// ```rust
1372/// use helios_sof::ChunkConfig;
1373///
1374/// // Default configuration (1000 resources per chunk)
1375/// let config = ChunkConfig::default();
1376///
1377/// // Custom configuration for memory-constrained environments
1378/// let config = ChunkConfig {
1379///     chunk_size: 100,
1380///     skip_invalid_lines: true,
1381/// };
1382/// ```
1383#[derive(Debug, Clone)]
1384pub struct ChunkConfig {
1385    /// Number of resources to process per chunk.
1386    /// Default: 1000 (approximately 10MB memory usage per chunk)
1387    pub chunk_size: usize,
1388    /// If true, skip lines that fail to parse as valid JSON.
1389    /// If false (default), return an error on the first invalid line.
1390    pub skip_invalid_lines: bool,
1391}
1392
1393impl Default for ChunkConfig {
1394    fn default() -> Self {
1395        Self {
1396            chunk_size: 1000,
1397            skip_invalid_lines: false,
1398        }
1399    }
1400}
1401
1402/// A chunk of parsed FHIR resources from an NDJSON file.
1403///
1404/// Represents a batch of resources that have been read and parsed,
1405/// ready for processing through a ViewDefinition.
1406#[derive(Debug)]
1407pub struct ResourceChunk {
1408    /// The parsed FHIR resources in this chunk
1409    pub resources: Vec<serde_json::Value>,
1410    /// Zero-based index of this chunk (0, 1, 2, ...)
1411    pub chunk_index: usize,
1412    /// True if this is the last chunk in the file
1413    pub is_last: bool,
1414}
1415
1416/// Result from processing a single chunk of resources.
1417///
1418/// Contains the output rows generated from processing one chunk,
1419/// along with metadata about the chunk position.
1420#[derive(Debug, Clone)]
1421pub struct ChunkedResult {
1422    /// Column names (same for all chunks)
1423    pub columns: Vec<String>,
1424    /// Processed rows from this chunk
1425    pub rows: Vec<ProcessedRow>,
1426    /// Zero-based index of this chunk
1427    pub chunk_index: usize,
1428    /// True if this is the last chunk
1429    pub is_last: bool,
1430    /// Number of resources that were in the input chunk
1431    pub resources_in_chunk: usize,
1432}
1433
1434/// Statistics from chunked processing.
1435///
1436/// Provides summary information about a completed chunked processing run.
1437#[derive(Debug, Clone, Default)]
1438pub struct ProcessingStats {
1439    /// Total number of lines read from the NDJSON file
1440    pub total_lines_read: usize,
1441    /// Number of FHIR resources successfully processed
1442    pub resources_processed: usize,
1443    /// Number of output rows generated
1444    pub output_rows: usize,
1445    /// Number of lines skipped due to parse errors (when skip_invalid_lines is true)
1446    pub skipped_lines: usize,
1447    /// Number of chunks processed
1448    pub chunks_processed: usize,
1449}
1450
1451/// Reads NDJSON files in chunks, yielding parsed resources.
1452///
1453/// This iterator reads an NDJSON file line by line, collecting resources
1454/// into chunks of the configured size. Each iteration yields a `ResourceChunk`
1455/// containing up to `chunk_size` parsed FHIR resources.
1456///
1457/// # Examples
1458///
1459/// ```rust,no_run
1460/// use helios_sof::{NdjsonChunkReader, ChunkConfig};
1461/// use std::io::BufReader;
1462/// use std::fs::File;
1463///
1464/// let file = File::open("patients.ndjson").unwrap();
1465/// let reader = BufReader::new(file);
1466/// let config = ChunkConfig::default();
1467///
1468/// let mut chunk_reader = NdjsonChunkReader::new(reader, config);
1469///
1470/// while let Some(result) = chunk_reader.next() {
1471///     match result {
1472///         Ok(chunk) => {
1473///             println!("Chunk {}: {} resources", chunk.chunk_index, chunk.resources.len());
1474///         }
1475///         Err(e) => {
1476///             eprintln!("Error reading chunk: {}", e);
1477///             break;
1478///         }
1479///     }
1480/// }
1481/// ```
1482pub struct NdjsonChunkReader<R: BufRead> {
1483    reader: R,
1484    config: ChunkConfig,
1485    current_chunk: usize,
1486    finished: bool,
1487    line_buffer: String,
1488    line_number: usize,
1489    /// Resource type filter - only include resources of this type
1490    resource_type_filter: Option<String>,
1491    /// Number of lines skipped due to invalid JSON
1492    skipped_lines: usize,
1493}
1494
1495impl<R: BufRead> NdjsonChunkReader<R> {
1496    /// Create a new NDJSON chunk reader with the given configuration.
1497    pub fn new(reader: R, config: ChunkConfig) -> Self {
1498        Self {
1499            reader,
1500            config,
1501            current_chunk: 0,
1502            finished: false,
1503            line_buffer: String::new(),
1504            line_number: 0,
1505            resource_type_filter: None,
1506            skipped_lines: 0,
1507        }
1508    }
1509
1510    /// Set a resource type filter to only include resources of a specific type.
1511    ///
1512    /// This is useful when processing NDJSON files that contain multiple resource types.
1513    pub fn with_resource_type_filter(mut self, resource_type: Option<String>) -> Self {
1514        self.resource_type_filter = resource_type;
1515        self
1516    }
1517
1518    /// Get the total number of lines read so far.
1519    pub fn lines_read(&self) -> usize {
1520        self.line_number
1521    }
1522
1523    /// Get the number of lines skipped due to invalid JSON.
1524    pub fn skipped_lines(&self) -> usize {
1525        self.skipped_lines
1526    }
1527}
1528
1529impl<R: BufRead> Iterator for NdjsonChunkReader<R> {
1530    type Item = Result<ResourceChunk, SofError>;
1531
1532    fn next(&mut self) -> Option<Self::Item> {
1533        if self.finished {
1534            return None;
1535        }
1536
1537        let mut resources = Vec::with_capacity(self.config.chunk_size);
1538
1539        while resources.len() < self.config.chunk_size {
1540            self.line_buffer.clear();
1541            match self.reader.read_line(&mut self.line_buffer) {
1542                Ok(0) => {
1543                    // EOF reached
1544                    self.finished = true;
1545                    break;
1546                }
1547                Ok(_) => {
1548                    self.line_number += 1;
1549                    let line = self.line_buffer.trim();
1550
1551                    // Skip empty lines
1552                    if line.is_empty() {
1553                        continue;
1554                    }
1555
1556                    // Parse the JSON
1557                    match serde_json::from_str::<serde_json::Value>(line) {
1558                        Ok(value) => {
1559                            // Apply resource type filter if set
1560                            if let Some(ref filter) = self.resource_type_filter {
1561                                let resource_type =
1562                                    value.get("resourceType").and_then(|v| v.as_str());
1563                                if resource_type != Some(filter.as_str()) {
1564                                    continue;
1565                                }
1566                            }
1567                            resources.push(value);
1568                        }
1569                        Err(e) => {
1570                            if self.config.skip_invalid_lines {
1571                                // Skip this line and continue
1572                                self.skipped_lines += 1;
1573                                continue;
1574                            } else {
1575                                return Some(Err(SofError::InvalidSourceContent(format!(
1576                                    "Invalid JSON at line {}: {}",
1577                                    self.line_number, e
1578                                ))));
1579                            }
1580                        }
1581                    }
1582                }
1583                Err(e) => {
1584                    return Some(Err(SofError::IoError(e)));
1585                }
1586            }
1587        }
1588
1589        // If we have no resources and we're finished, don't return an empty chunk
1590        if resources.is_empty() && self.finished {
1591            return None;
1592        }
1593
1594        let chunk = ResourceChunk {
1595            resources,
1596            chunk_index: self.current_chunk,
1597            is_last: self.finished,
1598        };
1599        self.current_chunk += 1;
1600
1601        Some(Ok(chunk))
1602    }
1603}
1604
1605/// Pre-validated ViewDefinition for efficient reuse across multiple chunks.
1606///
1607/// This struct caches the validation and constant extraction from a ViewDefinition,
1608/// allowing efficient processing of multiple chunks without re-validating each time.
1609///
1610/// # Examples
1611///
1612/// ```rust,no_run
1613/// use helios_sof::{PreparedViewDefinition, SofViewDefinition, ResourceChunk};
1614///
1615/// # #[cfg(feature = "R4")]
1616/// # {
1617/// // Parse and prepare ViewDefinition once
1618/// let view_json: serde_json::Value = serde_json::from_str(r#"{
1619///     "resourceType": "ViewDefinition",
1620///     "resource": "Patient",
1621///     "select": [{"column": [{"name": "id", "path": "id"}]}]
1622/// }"#).unwrap();
1623/// let view_def: helios_fhir::r4::ViewDefinition = serde_json::from_value(view_json).unwrap();
1624/// let sof_view = SofViewDefinition::R4(view_def);
1625///
1626/// let prepared = PreparedViewDefinition::new(sof_view).unwrap();
1627///
1628/// // Process multiple chunks efficiently
1629/// // for chunk in chunk_iterator {
1630/// //     let result = prepared.process_chunk(chunk)?;
1631/// //     // ... handle result
1632/// // }
1633/// # }
1634/// ```
1635#[derive(Debug, Clone)]
1636pub struct PreparedViewDefinition {
1637    view_definition: SofViewDefinition,
1638    target_resource_type: String,
1639    variables: HashMap<String, EvaluationResult>,
1640    column_names: Vec<String>,
1641}
1642
1643impl PreparedViewDefinition {
1644    /// Create a new PreparedViewDefinition by validating and extracting metadata.
1645    ///
1646    /// This performs all validation upfront so that chunk processing is efficient.
1647    pub fn new(view_definition: SofViewDefinition) -> Result<Self, SofError> {
1648        // Extract target resource type and column names based on version
1649        let (target_resource_type, variables, column_names) = match &view_definition {
1650            #[cfg(feature = "R4")]
1651            SofViewDefinition::R4(vd) => {
1652                validate_view_definition(vd)?;
1653                let vars = extract_view_definition_constants(vd)?;
1654                let resource_type = vd
1655                    .resource()
1656                    .ok_or_else(|| {
1657                        SofError::InvalidViewDefinition("Resource type is required".to_string())
1658                    })?
1659                    .to_string();
1660                let mut columns = Vec::new();
1661                if let Some(selects) = vd.select() {
1662                    collect_all_columns(selects, &mut columns)?;
1663                }
1664                (resource_type, vars, columns)
1665            }
1666            #[cfg(feature = "R4B")]
1667            SofViewDefinition::R4B(vd) => {
1668                validate_view_definition(vd)?;
1669                let vars = extract_view_definition_constants(vd)?;
1670                let resource_type = vd
1671                    .resource()
1672                    .ok_or_else(|| {
1673                        SofError::InvalidViewDefinition("Resource type is required".to_string())
1674                    })?
1675                    .to_string();
1676                let mut columns = Vec::new();
1677                if let Some(selects) = vd.select() {
1678                    collect_all_columns(selects, &mut columns)?;
1679                }
1680                (resource_type, vars, columns)
1681            }
1682            #[cfg(feature = "R5")]
1683            SofViewDefinition::R5(vd) => {
1684                validate_view_definition(vd)?;
1685                let vars = extract_view_definition_constants(vd)?;
1686                let resource_type = vd
1687                    .resource()
1688                    .ok_or_else(|| {
1689                        SofError::InvalidViewDefinition("Resource type is required".to_string())
1690                    })?
1691                    .to_string();
1692                let mut columns = Vec::new();
1693                if let Some(selects) = vd.select() {
1694                    collect_all_columns(selects, &mut columns)?;
1695                }
1696                (resource_type, vars, columns)
1697            }
1698            #[cfg(feature = "R6")]
1699            SofViewDefinition::R6(vd) => {
1700                validate_view_definition(vd)?;
1701                let vars = extract_view_definition_constants(vd)?;
1702                let resource_type = vd
1703                    .resource()
1704                    .ok_or_else(|| {
1705                        SofError::InvalidViewDefinition("Resource type is required".to_string())
1706                    })?
1707                    .to_string();
1708                let mut columns = Vec::new();
1709                if let Some(selects) = vd.select() {
1710                    collect_all_columns(selects, &mut columns)?;
1711                }
1712                (resource_type, vars, columns)
1713            }
1714        };
1715
1716        Ok(Self {
1717            view_definition,
1718            target_resource_type,
1719            variables,
1720            column_names,
1721        })
1722    }
1723
1724    /// Get the column names that will be produced by this ViewDefinition.
1725    pub fn columns(&self) -> &[String] {
1726        &self.column_names
1727    }
1728
1729    /// Get the target resource type for this ViewDefinition.
1730    pub fn target_resource_type(&self) -> &str {
1731        &self.target_resource_type
1732    }
1733
1734    /// Process a chunk of resources through this ViewDefinition.
1735    ///
1736    /// Returns a `ChunkedResult` containing the rows generated from the chunk.
1737    /// Uses parallel processing via rayon for improved throughput.
1738    pub fn process_chunk(&self, chunk: ResourceChunk) -> Result<ChunkedResult, SofError> {
1739        self.process_chunk_with_external(chunk, Vec::new())
1740    }
1741
1742    /// Like [`Self::process_chunk`], but folds `external` resources into this
1743    /// chunk's resolution pool. Used by the streaming remote-`resolve()` driver
1744    /// ([`process_ndjson_chunked_remote`]) to inject resources prefetched from
1745    /// trusted servers for references found in the chunk.
1746    pub fn process_chunk_with_external(
1747        &self,
1748        chunk: ResourceChunk,
1749        external: Vec<helios_fhir::FhirResource>,
1750    ) -> Result<ChunkedResult, SofError> {
1751        // Build the resolution pool for `resolve()` from the resources in this chunk
1752        // plus any remotely-prefetched `external` resources.
1753        //
1754        // NOTE: in-bundle resolution in the streaming path is limited to the
1755        // *current chunk* — a reference to a resource in another chunk of the same
1756        // input cannot be resolved locally (it falls back to a typed stub / empty).
1757        // Remote references (to allowlisted trusted servers) are resolved via the
1758        // `external` resources prefetched per chunk with a cross-chunk cache. The
1759        // non-streaming Bundle path resolves across the entire bundle.
1760        let version = self.view_definition.version();
1761        let mut pool: Vec<helios_fhir::FhirResource> = chunk
1762            .resources
1763            .iter()
1764            .filter_map(|json| parse_json_to_fhir_resource(json.clone(), version).ok())
1765            .collect();
1766        pool.extend(external);
1767        let resolution_scope: std::sync::Arc<Vec<helios_fhir::FhirResource>> =
1768            std::sync::Arc::new(pool);
1769
1770        // Process resources in parallel using rayon
1771        let results: Result<Vec<Vec<ProcessedRow>>, SofError> = chunk
1772            .resources
1773            .par_iter()
1774            .filter_map(|resource_json| {
1775                // Check resource type matches
1776                let resource_type = resource_json
1777                    .get("resourceType")
1778                    .and_then(|v| v.as_str())
1779                    .unwrap_or("");
1780
1781                if resource_type != self.target_resource_type {
1782                    None
1783                } else {
1784                    // Process single resource based on version
1785                    Some(self.process_single_resource(resource_json, &resolution_scope))
1786                }
1787            })
1788            .collect();
1789
1790        // Flatten results from all resources
1791        let all_rows: Vec<ProcessedRow> = results?.into_iter().flatten().collect();
1792
1793        Ok(ChunkedResult {
1794            columns: self.column_names.clone(),
1795            rows: all_rows,
1796            chunk_index: chunk.chunk_index,
1797            is_last: chunk.is_last,
1798            resources_in_chunk: chunk.resources.len(),
1799        })
1800    }
1801
1802    /// Process a single resource JSON value through the ViewDefinition.
1803    fn process_single_resource(
1804        &self,
1805        resource_json: &serde_json::Value,
1806        resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
1807    ) -> Result<Vec<ProcessedRow>, SofError> {
1808        match &self.view_definition {
1809            #[cfg(feature = "R4")]
1810            SofViewDefinition::R4(vd) => {
1811                self.process_single_resource_generic(vd, resource_json, resolution_scope)
1812            }
1813            #[cfg(feature = "R4B")]
1814            SofViewDefinition::R4B(vd) => {
1815                self.process_single_resource_generic(vd, resource_json, resolution_scope)
1816            }
1817            #[cfg(feature = "R5")]
1818            SofViewDefinition::R5(vd) => {
1819                self.process_single_resource_generic(vd, resource_json, resolution_scope)
1820            }
1821            #[cfg(feature = "R6")]
1822            SofViewDefinition::R6(vd) => {
1823                self.process_single_resource_generic(vd, resource_json, resolution_scope)
1824            }
1825        }
1826    }
1827
1828    fn process_single_resource_generic<VD>(
1829        &self,
1830        view_definition: &VD,
1831        resource_json: &serde_json::Value,
1832        resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
1833    ) -> Result<Vec<ProcessedRow>, SofError>
1834    where
1835        VD: ViewDefinitionTrait,
1836        VD::Select: ViewDefinitionSelectTrait,
1837    {
1838        // Create evaluation context from JSON by parsing into typed FhirResource
1839        let fhir_resource =
1840            parse_json_to_fhir_resource(resource_json.clone(), self.view_definition.version())?;
1841        let mut context = EvaluationContext::new(vec![fhir_resource]);
1842        // Expose the chunk-wide pool so `resolve()` can reach sibling resources.
1843        context.set_resolution_scope(std::sync::Arc::clone(resolution_scope));
1844
1845        // Add variables to the context
1846        for (name, value) in &self.variables {
1847            context.set_variable_result(name, value.clone());
1848        }
1849
1850        // Apply where clauses
1851        if let Some(where_clauses) = view_definition.where_clauses() {
1852            for where_clause in where_clauses {
1853                let path = where_clause.path().ok_or_else(|| {
1854                    SofError::InvalidViewDefinition("Where clause path is required".to_string())
1855                })?;
1856
1857                match evaluate_expression(path, &context) {
1858                    Ok(result) => {
1859                        if !can_be_coerced_to_boolean(&result) {
1860                            return Err(SofError::InvalidViewDefinition(format!(
1861                                "Where clause path '{}' returns type '{}' which cannot be used as a boolean condition.",
1862                                path,
1863                                result.type_name()
1864                            )));
1865                        }
1866                        if !is_truthy(&result) {
1867                            // Resource doesn't match where clause, return empty rows
1868                            return Ok(Vec::new());
1869                        }
1870                    }
1871                    Err(e) => {
1872                        return Err(SofError::FhirPathError(format!(
1873                            "Error evaluating where clause '{}': {}",
1874                            path, e
1875                        )));
1876                    }
1877                }
1878            }
1879        }
1880
1881        // Generate rows
1882        let select_clauses = view_definition.select().ok_or_else(|| {
1883            SofError::InvalidViewDefinition("At least one select clause is required".to_string())
1884        })?;
1885
1886        let mut all_columns = self.column_names.clone();
1887        generate_row_combinations(&context, select_clauses, &mut all_columns, &self.variables)
1888    }
1889}
1890
1891/// Iterator that combines NDJSON reading with ViewDefinition processing.
1892///
1893/// This iterator reads chunks from an NDJSON file and processes them
1894/// through a ViewDefinition, yielding `ChunkedResult` for each chunk.
1895///
1896/// # Examples
1897///
1898/// ```rust,no_run
1899/// use helios_sof::{NdjsonChunkIterator, SofViewDefinition, ChunkConfig};
1900/// use std::io::BufReader;
1901/// use std::fs::File;
1902///
1903/// # #[cfg(feature = "R4")]
1904/// # {
1905/// // Set up ViewDefinition
1906/// let view_json: serde_json::Value = serde_json::from_str(r#"{
1907///     "resourceType": "ViewDefinition",
1908///     "resource": "Patient",
1909///     "select": [{"column": [{"name": "id", "path": "id"}]}]
1910/// }"#).unwrap();
1911/// let view_def: helios_fhir::r4::ViewDefinition = serde_json::from_value(view_json).unwrap();
1912/// let sof_view = SofViewDefinition::R4(view_def);
1913///
1914/// // Process file in chunks
1915/// let file = File::open("patients.ndjson").unwrap();
1916/// let reader = BufReader::new(file);
1917///
1918/// let iterator = NdjsonChunkIterator::new(sof_view, reader, ChunkConfig::default()).unwrap();
1919///
1920/// for result in iterator {
1921///     match result {
1922///         Ok(chunk_result) => {
1923///             println!("Chunk {}: {} rows", chunk_result.chunk_index, chunk_result.rows.len());
1924///         }
1925///         Err(e) => {
1926///             eprintln!("Error: {}", e);
1927///             break;
1928///         }
1929///     }
1930/// }
1931/// # }
1932/// ```
1933pub struct NdjsonChunkIterator<R: BufRead> {
1934    reader: NdjsonChunkReader<R>,
1935    prepared_vd: PreparedViewDefinition,
1936}
1937
1938impl<R: BufRead> NdjsonChunkIterator<R> {
1939    /// Create a new chunk iterator from a ViewDefinition and NDJSON reader.
1940    pub fn new(
1941        view_definition: SofViewDefinition,
1942        reader: R,
1943        config: ChunkConfig,
1944    ) -> Result<Self, SofError> {
1945        let prepared_vd = PreparedViewDefinition::new(view_definition)?;
1946        let resource_type = prepared_vd.target_resource_type().to_string();
1947        let chunk_reader =
1948            NdjsonChunkReader::new(reader, config).with_resource_type_filter(Some(resource_type));
1949
1950        Ok(Self {
1951            reader: chunk_reader,
1952            prepared_vd,
1953        })
1954    }
1955
1956    /// Get the column names that will be produced by this iterator.
1957    pub fn columns(&self) -> &[String] {
1958        self.prepared_vd.columns()
1959    }
1960
1961    /// Get the total number of lines read so far.
1962    pub fn lines_read(&self) -> usize {
1963        self.reader.lines_read()
1964    }
1965
1966    /// Get the number of lines skipped due to invalid JSON.
1967    pub fn skipped_lines(&self) -> usize {
1968        self.reader.skipped_lines()
1969    }
1970}
1971
1972impl<R: BufRead> Iterator for NdjsonChunkIterator<R> {
1973    type Item = Result<ChunkedResult, SofError>;
1974
1975    fn next(&mut self) -> Option<Self::Item> {
1976        match self.reader.next()? {
1977            Ok(chunk) => Some(self.prepared_vd.process_chunk(chunk)),
1978            Err(e) => Some(Err(e)),
1979        }
1980    }
1981}
1982
1983// =============================================================================
1984// Streaming Output Functions
1985// =============================================================================
1986
1987/// Write CSV header row.
1988fn write_csv_header<W: Write>(columns: &[String], writer: &mut W) -> Result<(), SofError> {
1989    let mut wtr = csv::Writer::from_writer(writer);
1990    wtr.write_record(columns)?;
1991    wtr.flush()?;
1992    Ok(())
1993}
1994
1995/// Write CSV rows from a chunk (no header).
1996fn write_csv_chunk<W: Write>(result: &ChunkedResult, writer: &mut W) -> Result<(), SofError> {
1997    let mut wtr = csv::Writer::from_writer(writer);
1998
1999    for row in &result.rows {
2000        let record: Vec<String> = row
2001            .values
2002            .iter()
2003            .map(|v| match v {
2004                Some(val) => {
2005                    if let serde_json::Value::String(s) = val {
2006                        s.clone()
2007                    } else {
2008                        serde_json::to_string(val).unwrap_or_default()
2009                    }
2010                }
2011                None => String::new(),
2012            })
2013            .collect();
2014        wtr.write_record(&record)?;
2015    }
2016
2017    wtr.flush()?;
2018    Ok(())
2019}
2020
2021/// Write NDJSON rows from a chunk.
2022fn write_ndjson_chunk<W: Write>(result: &ChunkedResult, writer: &mut W) -> Result<(), SofError> {
2023    for row in &result.rows {
2024        let mut row_obj = serde_json::Map::new();
2025        for (i, column) in result.columns.iter().enumerate() {
2026            let value = row
2027                .values
2028                .get(i)
2029                .and_then(|v| v.as_ref())
2030                .cloned()
2031                .unwrap_or(serde_json::Value::Null);
2032            row_obj.insert(column.clone(), value);
2033        }
2034        let line = serde_json::to_string(&serde_json::Value::Object(row_obj))?;
2035        writer.write_all(line.as_bytes())?;
2036        writer.write_all(b"\n")?;
2037    }
2038
2039    Ok(())
2040}
2041
2042/// Process an NDJSON input stream and write output incrementally.
2043///
2044/// This is the main entry point for streaming/chunked NDJSON processing.
2045/// It reads the input in chunks, processes each chunk through the ViewDefinition,
2046/// and writes the output incrementally to the writer.
2047///
2048/// # Arguments
2049///
2050/// * `view_definition` - The ViewDefinition to execute
2051/// * `input` - A buffered reader for the NDJSON input
2052/// * `output` - A writer for the output (file, stdout, etc.)
2053/// * `content_type` - The desired output format (CSV, NDJSON, JSON)
2054/// * `config` - Configuration for chunk processing
2055///
2056/// # Returns
2057///
2058/// Statistics about the processing run, including row counts and chunk counts.
2059///
2060/// # Examples
2061///
2062/// ```rust,no_run
2063/// use helios_sof::{process_ndjson_chunked, SofViewDefinition, ContentType, ChunkConfig};
2064/// use std::io::{BufReader, BufWriter};
2065/// use std::fs::File;
2066///
2067/// # #[cfg(feature = "R4")]
2068/// # {
2069/// // Set up ViewDefinition
2070/// let view_json: serde_json::Value = serde_json::from_str(r#"{
2071///     "resourceType": "ViewDefinition",
2072///     "resource": "Patient",
2073///     "select": [{"column": [{"name": "id", "path": "id"}]}]
2074/// }"#).unwrap();
2075/// let view_def: helios_fhir::r4::ViewDefinition = serde_json::from_value(view_json).unwrap();
2076/// let sof_view = SofViewDefinition::R4(view_def);
2077///
2078/// // Process file
2079/// let input = BufReader::new(File::open("patients.ndjson").unwrap());
2080/// let mut output = BufWriter::new(File::create("output.csv").unwrap());
2081///
2082/// let stats = process_ndjson_chunked(
2083///     sof_view,
2084///     input,
2085///     &mut output,
2086///     ContentType::CsvWithHeader,
2087///     ChunkConfig::default(),
2088/// ).unwrap();
2089///
2090/// println!("Processed {} resources, {} output rows",
2091///     stats.resources_processed, stats.output_rows);
2092/// # }
2093/// ```
2094///
2095/// # Errors
2096///
2097/// Returns an error if:
2098/// - The ViewDefinition is invalid
2099/// - The input contains invalid JSON (when `skip_invalid_lines` is false)
2100/// - Writing to the output fails
2101/// - Parquet format is requested (not supported for streaming)
2102pub fn process_ndjson_chunked<R: BufRead, W: Write>(
2103    view_definition: SofViewDefinition,
2104    input: R,
2105    mut output: W,
2106    content_type: ContentType,
2107    config: ChunkConfig,
2108) -> Result<ProcessingStats, SofError> {
2109    // Validate content type supports streaming
2110    reject_columnar_for_streaming(content_type)?;
2111
2112    let mut iterator = NdjsonChunkIterator::new(view_definition, input, config)?;
2113    let columns = iterator.columns().to_vec();
2114
2115    let mut stats = ProcessingStats::default();
2116    let mut is_first_chunk = true;
2117
2118    // Write header if needed
2119    if content_type == ContentType::CsvWithHeader {
2120        write_csv_header(&columns, &mut output)?;
2121    }
2122
2123    // For JSON output, we need special handling to create a valid array
2124    if content_type == ContentType::Json {
2125        output.write_all(b"[\n")?;
2126    }
2127
2128    for result in iterator.by_ref() {
2129        let chunk_result = result?;
2130
2131        stats.resources_processed += chunk_result.resources_in_chunk;
2132        stats.output_rows += chunk_result.rows.len();
2133        stats.chunks_processed += 1;
2134
2135        write_chunk_output(
2136            &chunk_result,
2137            content_type,
2138            &mut output,
2139            &mut is_first_chunk,
2140        )?;
2141        output.flush()?;
2142    }
2143
2144    // Close JSON array if needed
2145    if content_type == ContentType::Json {
2146        output.write_all(b"\n]")?;
2147    }
2148
2149    output.flush()?;
2150
2151    // Update stats with line/skip counts from the iterator
2152    stats.total_lines_read = iterator.lines_read();
2153    stats.skipped_lines = iterator.skipped_lines();
2154
2155    Ok(stats)
2156}
2157
2158/// Writes one chunk's rows in the requested streaming format. Shared by the sync
2159/// ([`process_ndjson_chunked`]) and remote ([`process_ndjson_chunked_remote`])
2160/// drivers. `is_first_chunk` is consulted (and cleared) only for JSON, to manage
2161/// inter-row commas across chunks.
2162fn write_chunk_output<W: Write>(
2163    chunk_result: &ChunkedResult,
2164    content_type: ContentType,
2165    output: &mut W,
2166    is_first_chunk: &mut bool,
2167) -> Result<(), SofError> {
2168    match content_type {
2169        ContentType::Csv | ContentType::CsvWithHeader => {
2170            write_csv_chunk(chunk_result, output)?;
2171        }
2172        ContentType::NdJson => {
2173            write_ndjson_chunk(chunk_result, output)?;
2174        }
2175        ContentType::Json => {
2176            // Write JSON rows with proper comma handling
2177            for (i, row) in chunk_result.rows.iter().enumerate() {
2178                if !*is_first_chunk || i > 0 {
2179                    output.write_all(b",\n")?;
2180                }
2181
2182                let mut row_obj = serde_json::Map::new();
2183                for (j, column) in chunk_result.columns.iter().enumerate() {
2184                    let value = row
2185                        .values
2186                        .get(j)
2187                        .and_then(|v| v.as_ref())
2188                        .cloned()
2189                        .unwrap_or(serde_json::Value::Null);
2190                    row_obj.insert(column.clone(), value);
2191                }
2192                let json = serde_json::to_string_pretty(&serde_json::Value::Object(row_obj))?;
2193                output.write_all(json.as_bytes())?;
2194            }
2195        }
2196        // Caller rejects columnar formats via reject_columnar_for_streaming
2197        ContentType::Parquet | ContentType::ArrowIpc => unreachable!(),
2198    }
2199
2200    *is_first_chunk = false;
2201    Ok(())
2202}
2203
2204/// The columnar formats need the full result (or a stateful writer holding the
2205/// schema) and are not supported by the chunk-at-a-time NDJSON drivers.
2206fn reject_columnar_for_streaming(content_type: ContentType) -> Result<(), SofError> {
2207    match content_type {
2208        ContentType::Parquet => Err(SofError::UnsupportedContentType(
2209            "Parquet output is not supported for streaming. Use batch processing instead."
2210                .to_string(),
2211        )),
2212        ContentType::ArrowIpc => Err(SofError::UnsupportedContentType(
2213            "Arrow IPC output is not supported for streaming. Use batch processing instead."
2214                .to_string(),
2215        )),
2216        _ => Ok(()),
2217    }
2218}
2219
2220/// Streaming NDJSON processing with remote `resolve()` enabled.
2221///
2222/// Like [`process_ndjson_chunked`], but for each chunk it prefetches references
2223/// pointing at trusted (allowlisted) servers and folds them into that chunk's
2224/// resolution pool before row generation. A single [`RemoteResolver`] is shared
2225/// across all chunks, so a reference recurring across chunks is fetched once
2226/// (bounded LRU cache) and `SOF_RESOLVE_MAX_FETCHES` is a **per-stream** cap.
2227///
2228/// In-bundle resolution remains per-chunk (a reference to a resource in another
2229/// chunk of the same input is not resolved locally). When `remote_config` is
2230/// inactive this is equivalent to [`process_ndjson_chunked`].
2231pub async fn process_ndjson_chunked_remote<R: BufRead, W: Write>(
2232    view_definition: SofViewDefinition,
2233    input: R,
2234    mut output: W,
2235    content_type: ContentType,
2236    config: ChunkConfig,
2237    remote_config: &RemoteResolveConfig,
2238) -> Result<ProcessingStats, SofError> {
2239    reject_columnar_for_streaming(content_type)?;
2240
2241    let version = view_definition.version();
2242    let prepared = PreparedViewDefinition::new(view_definition)?;
2243    let resource_type = prepared.target_resource_type().to_string();
2244    let columns = prepared.columns().to_vec();
2245    let mut reader =
2246        NdjsonChunkReader::new(input, config).with_resource_type_filter(Some(resource_type));
2247
2248    // One resolver for the whole stream: cross-chunk cache + per-stream fetch cap.
2249    let resolver = remote_fetch::RemoteResolver::new(remote_config.clone());
2250    let active = remote_config.is_active();
2251
2252    let mut stats = ProcessingStats::default();
2253    let mut is_first_chunk = true;
2254
2255    if content_type == ContentType::CsvWithHeader {
2256        write_csv_header(&columns, &mut output)?;
2257    }
2258    if content_type == ContentType::Json {
2259        output.write_all(b"[\n")?;
2260    }
2261
2262    for chunk in reader.by_ref() {
2263        let chunk = chunk?;
2264
2265        let external = if active {
2266            let refs =
2267                reference_collector::collect_reference_strings_from_resources(&chunk.resources);
2268            let keys = reference_collector::collect_resource_keys_from_resources(&chunk.resources);
2269            resolver.resolve(refs, &keys, version).await
2270        } else {
2271            Vec::new()
2272        };
2273
2274        let chunk_result = prepared.process_chunk_with_external(chunk, external)?;
2275
2276        stats.resources_processed += chunk_result.resources_in_chunk;
2277        stats.output_rows += chunk_result.rows.len();
2278        stats.chunks_processed += 1;
2279
2280        write_chunk_output(
2281            &chunk_result,
2282            content_type,
2283            &mut output,
2284            &mut is_first_chunk,
2285        )?;
2286        output.flush()?;
2287    }
2288
2289    if content_type == ContentType::Json {
2290        output.write_all(b"\n]")?;
2291    }
2292    output.flush()?;
2293
2294    stats.total_lines_read = reader.lines_read();
2295    stats.skipped_lines = reader.skipped_lines();
2296
2297    Ok(stats)
2298}
2299
2300/// Create an iterator for chunked NDJSON processing.
2301///
2302/// This is a convenience function that creates an `NdjsonChunkIterator`.
2303/// Use this when you want more control over how chunks are processed.
2304///
2305/// # Arguments
2306///
2307/// * `view_definition` - The ViewDefinition to execute
2308/// * `reader` - A buffered reader for the NDJSON input
2309/// * `config` - Configuration for chunk processing
2310///
2311/// # Returns
2312///
2313/// An iterator that yields `ChunkedResult` for each processed chunk.
2314pub fn iter_ndjson_chunks<R: BufRead>(
2315    view_definition: SofViewDefinition,
2316    reader: R,
2317    config: ChunkConfig,
2318) -> Result<NdjsonChunkIterator<R>, SofError> {
2319    NdjsonChunkIterator::new(view_definition, reader, config)
2320}
2321
2322// =============================================================================
2323// End Streaming/Chunked Processing Types
2324// =============================================================================
2325
2326/// Parse a JSON value into a [`helios_fhir::FhirResource`] for the given FHIR version.
2327///
2328/// Used for streaming/chunked processing where raw JSON must be converted to typed
2329/// resources for FHIRPath evaluation. It is the stable entry point callers use to
2330/// build the `external` resources passed to
2331/// [`PreparedViewDefinition::process_chunk_with_external`] — e.g. the compartment
2332/// filter, the remote-`resolve()` prefetch, and the persistence layer's
2333/// storage-backed `resolve()` prefetch. Wraps the private
2334/// [`parse_json_to_fhir_resource`].
2335pub fn parse_json_to_fhir_resource_pub(
2336    json: serde_json::Value,
2337    version: FhirVersion,
2338) -> Result<helios_fhir::FhirResource, SofError> {
2339    parse_json_to_fhir_resource(json, version)
2340}
2341
2342fn parse_json_to_fhir_resource(
2343    json: serde_json::Value,
2344    version: FhirVersion,
2345) -> Result<helios_fhir::FhirResource, SofError> {
2346    match version {
2347        #[cfg(feature = "R4")]
2348        FhirVersion::R4 => {
2349            let resource: helios_fhir::r4::Resource =
2350                serde_json::from_value(json).map_err(|e| {
2351                    SofError::InvalidSourceContent(format!("Invalid R4 resource: {}", e))
2352                })?;
2353            Ok(helios_fhir::FhirResource::R4(Box::new(resource)))
2354        }
2355        #[cfg(feature = "R4B")]
2356        FhirVersion::R4B => {
2357            let resource: helios_fhir::r4b::Resource =
2358                serde_json::from_value(json).map_err(|e| {
2359                    SofError::InvalidSourceContent(format!("Invalid R4B resource: {}", e))
2360                })?;
2361            Ok(helios_fhir::FhirResource::R4B(Box::new(resource)))
2362        }
2363        #[cfg(feature = "R5")]
2364        FhirVersion::R5 => {
2365            let resource: helios_fhir::r5::Resource =
2366                serde_json::from_value(json).map_err(|e| {
2367                    SofError::InvalidSourceContent(format!("Invalid R5 resource: {}", e))
2368                })?;
2369            Ok(helios_fhir::FhirResource::R5(Box::new(resource)))
2370        }
2371        #[cfg(feature = "R6")]
2372        FhirVersion::R6 => {
2373            let resource: helios_fhir::r6::Resource =
2374                serde_json::from_value(json).map_err(|e| {
2375                    SofError::InvalidSourceContent(format!("Invalid R6 resource: {}", e))
2376                })?;
2377            Ok(helios_fhir::FhirResource::R6(Box::new(resource)))
2378        }
2379        // See the note in `parse_view_definition_for_version`: the variant can
2380        // exist without helios-sof having the matching feature enabled.
2381        #[allow(unreachable_patterns)]
2382        _ => Err(SofError::InvalidSourceContent(format!(
2383            "helios-sof was not compiled with support for FHIR version {:?}",
2384            version
2385        ))),
2386    }
2387}
2388
2389/// Execute a ViewDefinition transformation with additional filtering options.
2390///
2391/// This function extends the basic `run_view_definition` with support for:
2392/// - Filtering resources by modification time (`since`)
2393/// - Limiting results (`limit`)
2394/// - Pagination (`page`)
2395///
2396/// # Arguments
2397///
2398/// * `view_definition` - The ViewDefinition to execute
2399/// * `bundle` - The Bundle containing resources to transform
2400/// * `content_type` - Desired output format
2401/// * `options` - Additional filtering and control options
2402///
2403/// # Returns
2404///
2405/// The transformed data in the requested format, with filtering applied.
2406pub fn run_view_definition_with_options(
2407    view_definition: SofViewDefinition,
2408    bundle: SofBundle,
2409    content_type: ContentType,
2410    options: RunOptions,
2411) -> Result<Vec<u8>, SofError> {
2412    // Filter bundle resources by since parameter before processing
2413    let filtered_bundle = if let Some(since) = options.since {
2414        filter_bundle_by_since(bundle, since)?
2415    } else {
2416        bundle
2417    };
2418
2419    run_view_definition_inner(
2420        view_definition,
2421        filtered_bundle,
2422        content_type,
2423        options,
2424        Vec::new(),
2425    )
2426}
2427
2428/// Runs a ViewDefinition with remote `resolve()` enabled.
2429///
2430/// When `config` is active, references pointing at trusted (allowlisted) servers
2431/// are fetched up-front and folded into the resolution pool before the
2432/// (synchronous) row generation runs. When inactive, this behaves exactly like
2433/// [`run_view_definition_with_options`]. Remote resolution applies to the
2434/// (non-streaming) Bundle path only.
2435pub async fn run_view_definition_with_options_remote(
2436    view_definition: SofViewDefinition,
2437    bundle: SofBundle,
2438    content_type: ContentType,
2439    options: RunOptions,
2440    config: &RemoteResolveConfig,
2441) -> Result<Vec<u8>, SofError> {
2442    let filtered_bundle = if let Some(since) = options.since {
2443        filter_bundle_by_since(bundle, since)?
2444    } else {
2445        bundle
2446    };
2447
2448    let external = if config.is_active() {
2449        remote_fetch::prefetch_external_resources(&filtered_bundle, config).await
2450    } else {
2451        Vec::new()
2452    };
2453
2454    run_view_definition_inner(
2455        view_definition,
2456        filtered_bundle,
2457        content_type,
2458        options,
2459        external,
2460    )
2461}
2462
2463/// Shared tail of the run pipeline: process (with any external resources) →
2464/// paginate → format. The `bundle` must already be `since`-filtered.
2465fn run_view_definition_inner(
2466    view_definition: SofViewDefinition,
2467    bundle: SofBundle,
2468    content_type: ContentType,
2469    options: RunOptions,
2470    external: Vec<helios_fhir::FhirResource>,
2471) -> Result<Vec<u8>, SofError> {
2472    // Process the ViewDefinition to generate tabular data
2473    let processed_result =
2474        process_view_definition_with_external(view_definition, bundle, external)?;
2475
2476    // Apply pagination if needed
2477    let processed_result = if options.limit.is_some() || options.page.is_some() {
2478        apply_pagination_to_result(processed_result, options.limit, options.page)?
2479    } else {
2480        processed_result
2481    };
2482
2483    // Format the result according to the requested content type
2484    format_output(
2485        processed_result,
2486        content_type,
2487        options.parquet_options.as_ref(),
2488    )
2489}
2490
2491pub fn process_view_definition(
2492    view_definition: SofViewDefinition,
2493    bundle: SofBundle,
2494) -> Result<ProcessedResult, SofError> {
2495    process_view_definition_with_external(view_definition, bundle, Vec::new())
2496}
2497
2498/// Like [`process_view_definition`], but folds `external` resources (fetched by the
2499/// remote `resolve()` prefetch) into the resolution pool. `external` must already
2500/// be parsed in the bundle's FHIR version.
2501pub fn process_view_definition_with_external(
2502    view_definition: SofViewDefinition,
2503    bundle: SofBundle,
2504    external: Vec<helios_fhir::FhirResource>,
2505) -> Result<ProcessedResult, SofError> {
2506    // Ensure both resources use the same FHIR version
2507    if view_definition.version() != bundle.version() {
2508        return Err(SofError::InvalidViewDefinition(
2509            "ViewDefinition and Bundle must use the same FHIR version".to_string(),
2510        ));
2511    }
2512
2513    match (view_definition, bundle) {
2514        #[cfg(feature = "R4")]
2515        (SofViewDefinition::R4(vd), SofBundle::R4(bundle)) => {
2516            process_view_definition_generic(vd, bundle, external)
2517        }
2518        #[cfg(feature = "R4B")]
2519        (SofViewDefinition::R4B(vd), SofBundle::R4B(bundle)) => {
2520            process_view_definition_generic(vd, bundle, external)
2521        }
2522        #[cfg(feature = "R5")]
2523        (SofViewDefinition::R5(vd), SofBundle::R5(bundle)) => {
2524            process_view_definition_generic(vd, bundle, external)
2525        }
2526        #[cfg(feature = "R6")]
2527        (SofViewDefinition::R6(vd), SofBundle::R6(bundle)) => {
2528            process_view_definition_generic(vd, bundle, external)
2529        }
2530        // This case should never happen due to the version check above,
2531        // but is needed for exhaustive pattern matching when multiple features are enabled
2532        #[cfg(any(
2533            all(feature = "R4", any(feature = "R4B", feature = "R5", feature = "R6")),
2534            all(feature = "R4B", any(feature = "R5", feature = "R6")),
2535            all(feature = "R5", feature = "R6")
2536        ))]
2537        _ => {
2538            unreachable!("Version mismatch should have been caught by the version check above")
2539        }
2540    }
2541}
2542
2543// Generic version-agnostic constant extraction
2544fn extract_view_definition_constants<VD: ViewDefinitionTrait>(
2545    view_definition: &VD,
2546) -> Result<HashMap<String, EvaluationResult>, SofError> {
2547    let mut variables = HashMap::new();
2548
2549    // `%rowIndex` is the FHIRPath environment variable tracking the 0-based position of the
2550    // current element during iteration. It defaults to 0 at the resource/top level (and in any
2551    // non-iterating scope, such as a `unionAll` branch without `forEach`). `forEach`,
2552    // `forEachOrNull`, and `repeat` override it per iterated element (see
2553    // `expand_for_each_combinations` / `expand_repeat_combinations`).
2554    variables.insert("%rowIndex".to_string(), EvaluationResult::integer(0));
2555
2556    if let Some(constants) = view_definition.constants() {
2557        for constant in constants {
2558            let name = constant
2559                .name()
2560                .ok_or_else(|| {
2561                    SofError::InvalidViewDefinition("Constant name is required".to_string())
2562                })?
2563                .to_string();
2564
2565            let eval_result = constant.to_evaluation_result()?;
2566            // Constants are referenced with % prefix in FHIRPath expressions
2567            variables.insert(format!("%{}", name), eval_result);
2568        }
2569    }
2570
2571    Ok(variables)
2572}
2573
2574// Generic version-agnostic ViewDefinition processing
2575pub(crate) fn process_view_definition_generic<VD, B>(
2576    view_definition: VD,
2577    bundle: B,
2578    external: Vec<helios_fhir::FhirResource>,
2579) -> Result<ProcessedResult, SofError>
2580where
2581    VD: ViewDefinitionTrait + Serialize,
2582    B: BundleTrait,
2583    B::Resource: ResourceTrait + Sync,
2584    VD::Select: Sync,
2585{
2586    validate_view_definition(&view_definition)?;
2587
2588    // Step 1: Extract constants/variables from ViewDefinition
2589    let variables = extract_view_definition_constants(&view_definition)?;
2590
2591    // Step 2: Filter resources by type and profile
2592    let target_resource_type = view_definition
2593        .resource()
2594        .ok_or_else(|| SofError::InvalidViewDefinition("Resource type is required".to_string()))?;
2595
2596    // Build the bundle-wide resolution scope *before* filtering by resource type.
2597    // `resolve()` must be able to reach any resource in the input bundle (e.g.
2598    // `Encounter.subject.resolve()` -> a sibling `Patient`), not just resources of
2599    // the ViewDefinition's target type. This is parsed once and shared (via `Arc`)
2600    // across every per-resource and per-iteration evaluation context below.
2601    // `external` contributes remotely-prefetched resources to the same pool.
2602    let resolution_scope = build_resolution_scope(&bundle, external);
2603
2604    let filtered_resources = filter_resources(&bundle, target_resource_type)?;
2605
2606    // Step 3: Apply where clauses to filter resources
2607    let filtered_resources = apply_where_clauses(
2608        filtered_resources,
2609        view_definition.where_clauses(),
2610        &variables,
2611        &resolution_scope,
2612    )?;
2613
2614    // Step 4: Process all select clauses to generate rows with forEach support
2615    let select_clauses = view_definition.select().ok_or_else(|| {
2616        SofError::InvalidViewDefinition("At least one select clause is required".to_string())
2617    })?;
2618
2619    // Generate rows for each resource using the forEach-aware approach
2620    let (all_columns, rows) = generate_rows_from_selects(
2621        &filtered_resources,
2622        select_clauses,
2623        &variables,
2624        &resolution_scope,
2625    )?;
2626
2627    Ok(ProcessedResult {
2628        columns: all_columns,
2629        rows,
2630    })
2631}
2632
2633/// Renders a lint diagnostic as `"<message> at <pointer>"` for
2634/// [`validate_view_definition`]'s plain-text `SofError` messages — the
2635/// document root's own pointer (`""`) renders as `/` rather than a blank
2636/// suffix.
2637fn at_pointer(diagnostic: &lint::Diagnostic) -> String {
2638    let pointer: &str = if diagnostic.pointer.is_empty() {
2639        "/"
2640    } else {
2641        &diagnostic.pointer
2642    };
2643    format!("{} at {}", diagnostic.message, pointer)
2644}
2645
2646/// Generic, version-agnostic ViewDefinition validation.
2647///
2648/// Delegates structural validation to [`lint::lint_view_definition`] (#821)
2649/// — the same engine `$sql-run`'s handler runs against the raw request JSON
2650/// — so `sof-cli`, `pysof`, and any other caller of this library see
2651/// identical rules and messages. This re-lints a document the HTTP handler
2652/// may already have linted once; that's expected, not a bug: this function
2653/// is reached by every [`PreparedViewDefinition::new`] caller, including
2654/// ones that never went through that handler at all, so it has to be able
2655/// to catch the same problems on its own.
2656///
2657/// # Which `SofError` variant
2658///
2659/// A structural problem is reported as [`SofError::InvalidViewDefinition`],
2660/// **except** when every error-severity diagnostic the lint found is a
2661/// [`lint::DiagnosticCode::FhirPathSyntax`] one — a FHIRPath expression that
2662/// doesn't parse. That case reports [`SofError::FhirPathError`] instead, to
2663/// preserve the exception *type* `sof-cli` and `pysof` callers have always
2664/// been able to match on for a syntax problem (see
2665/// `crates/pysof/src/lib.rs`), from before this function caught it here
2666/// rather than only during evaluation. A document with a syntax error
2667/// *and* some other structural problem reports `InvalidViewDefinition`.
2668fn validate_view_definition<VD: ViewDefinitionTrait + Serialize>(
2669    view_def: &VD,
2670) -> Result<(), SofError> {
2671    let mut json = serde_json::to_value(view_def).map_err(|e| {
2672        SofError::InvalidViewDefinition(format!(
2673            "failed to serialize ViewDefinition for validation: {e}"
2674        ))
2675    })?;
2676    // The generated FHIR struct carries no `resourceType` field of its own —
2677    // it's implied by which enum variant / concrete type the caller is on,
2678    // not part of the struct's own JSON shape (see the
2679    // `validate_view_definition_lints_a_view_definition_missing_resource_type_in_its_own_serialization`
2680    // test) — so a plain serialization never includes it. Add it back
2681    // before linting, or `lint_view_definition` reports a spurious
2682    // `not-a-view-definition` for every otherwise-valid document.
2683    if let Some(object) = json.as_object_mut() {
2684        object.insert(
2685            "resourceType".to_string(),
2686            serde_json::Value::String("ViewDefinition".to_string()),
2687        );
2688    }
2689
2690    let error_diagnostics: Vec<lint::Diagnostic> = lint::lint_view_definition(&json)
2691        .into_iter()
2692        .filter(|diagnostic| diagnostic.severity == lint::Severity::Error)
2693        .collect();
2694    if !error_diagnostics.is_empty() {
2695        // `sof-cli` and `pysof` distinguish failures by `SofError` *variant*
2696        // (see `crates/pysof/src/lib.rs`'s `RustSofError` -> `PyErr` mapping:
2697        // `InvalidViewDefinitionError` vs. `FhirPathError`), not just by
2698        // message text, and did so before #821 introduced this lint-based
2699        // path. A FHIRPath expression that fails to parse used to only be
2700        // caught much later, during evaluation, and surfaced there as
2701        // `FhirPathError`; catching it here instead — earlier, and located
2702        // by pointer — must keep reporting it under the same variant, or a
2703        // caller that matches on the exception type breaks. So: if every
2704        // error the lint found is a syntax problem, this is still a
2705        // `FhirPathError`. Every other structural rule the lint enforces is
2706        // new behavior this crate never validated before #821, and keeps
2707        // `InvalidViewDefinition`; a document mixing a syntax error with
2708        // another kind of problem reports as `InvalidViewDefinition` too —
2709        // "something is structurally broken" is the more general failure
2710        // whenever both apply.
2711        let all_fhirpath_syntax = error_diagnostics
2712            .iter()
2713            .all(|diagnostic| diagnostic.code == lint::DiagnosticCode::FhirPathSyntax);
2714
2715        return if all_fhirpath_syntax {
2716            let messages: Vec<String> = error_diagnostics
2717                .iter()
2718                .map(|diagnostic| format!("Invalid FHIRPath syntax: {}", at_pointer(diagnostic)))
2719                .collect();
2720            Err(SofError::FhirPathError(messages.join("; ")))
2721        } else {
2722            let messages: Vec<String> = error_diagnostics.iter().map(at_pointer).collect();
2723            Err(SofError::InvalidViewDefinition(messages.join("; ")))
2724        };
2725    }
2726
2727    // Checks that depend on the relationship between selects — how a
2728    // `collection` attribute reads against an ancestor's `forEach`/
2729    // `forEachOrNull`/`repeat`, and whether `unionAll` branches agree on
2730    // column names and order — which the structural lint above deliberately
2731    // doesn't model (its own module docs: "structural and syntactic only").
2732    if let Some(selects) = view_def.select() {
2733        for select in selects {
2734            validate_select(select)?;
2735        }
2736    }
2737
2738    Ok(())
2739}
2740
2741// Generic helper - no longer needs to be version-specific
2742fn can_be_coerced_to_boolean(result: &EvaluationResult) -> bool {
2743    // Check if the result can be meaningfully used as a boolean in a where clause
2744    match result {
2745        // Boolean values are obviously OK
2746        EvaluationResult::Boolean(_, _, _) => true,
2747
2748        // Empty is OK (evaluates to false)
2749        EvaluationResult::Empty => true,
2750
2751        // Collections are OK - they evaluate based on whether they're empty or not
2752        EvaluationResult::Collection { .. } => true,
2753
2754        // Other types cannot be meaningfully coerced to boolean for where clauses
2755        // This includes: String, Integer, Decimal, Date, DateTime, Time, Quantity, Object
2756        _ => false,
2757    }
2758}
2759
2760// Generic select validation
2761fn validate_select<S: ViewDefinitionSelectTrait>(select: &S) -> Result<(), SofError> {
2762    validate_select_with_context(select, false)
2763}
2764
2765fn validate_select_with_context<S: ViewDefinitionSelectTrait>(
2766    select: &S,
2767    in_foreach_context: bool,
2768) -> Result<(), SofError>
2769where
2770    S::Select: ViewDefinitionSelectTrait,
2771{
2772    // The `sql-expressions` invariant that a select carries at most one of
2773    // `forEach`/`forEachOrNull`/`repeat` is structural — `lint_view_definition`
2774    // already reports every violation of it (#821) — so it is not
2775    // re-checked here. What *is* checked below (the `collection` attribute
2776    // and unionAll column consistency) depends on how selects relate to
2777    // each other, which the lint deliberately doesn't model.
2778
2779    // Determine if we're entering an iteration context at this level. `repeat`
2780    // counts here alongside forEach/forEachOrNull: it iterates the traversed nodes
2781    // one row apiece, so a column under it is single-valued for the same reason.
2782    let entering_foreach = select.for_each().is_some()
2783        || select.for_each_or_null().is_some()
2784        || select.repeat().is_some();
2785    let current_foreach_context = in_foreach_context || entering_foreach;
2786
2787    // Validate collection attribute with the current forEach context
2788    if let Some(columns) = select.column() {
2789        for column in columns {
2790            if let Some(collection_value) = column.collection() {
2791                if !collection_value && !current_foreach_context {
2792                    return Err(SofError::InvalidViewDefinition(
2793                        "Column 'collection' attribute must be true when specified".to_string(),
2794                    ));
2795                }
2796            }
2797        }
2798    }
2799
2800    // Validate unionAll column consistency
2801    if let Some(union_selects) = select.union_all() {
2802        validate_union_all_columns(union_selects)?;
2803    }
2804
2805    // Recursively validate nested selects
2806    if let Some(nested_selects) = select.select() {
2807        for nested_select in nested_selects {
2808            validate_select_with_context(nested_select, current_foreach_context)?;
2809        }
2810    }
2811
2812    // Validate unionAll selects with forEach context
2813    if let Some(union_selects) = select.union_all() {
2814        for union_select in union_selects {
2815            validate_select_with_context(union_select, current_foreach_context)?;
2816        }
2817    }
2818
2819    Ok(())
2820}
2821
2822// Generic union validation
2823fn validate_union_all_columns<S: ViewDefinitionSelectTrait>(
2824    union_selects: &[S],
2825) -> Result<(), SofError> {
2826    if union_selects.len() < 2 {
2827        return Ok(());
2828    }
2829
2830    // Get column names and order from first select
2831    let first_select = &union_selects[0];
2832    let first_columns = get_column_names(first_select)?;
2833
2834    // Validate all other selects have the same column names in the same order
2835    for (index, union_select) in union_selects.iter().enumerate().skip(1) {
2836        let current_columns = get_column_names(union_select)?;
2837
2838        if current_columns != first_columns {
2839            if current_columns.len() != first_columns.len()
2840                || !current_columns
2841                    .iter()
2842                    .all(|name| first_columns.contains(name))
2843            {
2844                return Err(SofError::InvalidViewDefinition(format!(
2845                    "UnionAll branch {} has different column names than first branch",
2846                    index
2847                )));
2848            } else {
2849                return Err(SofError::InvalidViewDefinition(format!(
2850                    "UnionAll branch {} has columns in different order than first branch",
2851                    index
2852                )));
2853            }
2854        }
2855    }
2856
2857    Ok(())
2858}
2859
2860// Generic column name extraction
2861fn get_column_names<S: ViewDefinitionSelectTrait>(select: &S) -> Result<Vec<String>, SofError> {
2862    let mut column_names = Vec::new();
2863
2864    // Collect direct column names
2865    if let Some(columns) = select.column() {
2866        for column in columns {
2867            if let Some(name) = column.name() {
2868                column_names.push(name.to_string());
2869            }
2870        }
2871    }
2872
2873    // If this select has unionAll but no direct columns, get columns from first unionAll branch
2874    if column_names.is_empty() {
2875        if let Some(union_selects) = select.union_all() {
2876            if !union_selects.is_empty() {
2877                return get_column_names(&union_selects[0]);
2878            }
2879        }
2880    }
2881
2882    Ok(column_names)
2883}
2884
2885/// Builds the bundle-wide resolution pool that `resolve()` searches.
2886///
2887/// Every resource in the input bundle — regardless of type — is parsed into a
2888/// version-agnostic [`helios_fhir::FhirResource`] once and shared via `Arc`, so
2889/// that `Reference.resolve()` can dereference to any sibling resource in the
2890/// bundle (not only resources of the ViewDefinition's target type, and not only
2891/// `contained` children of the resource under evaluation).
2892///
2893/// The returned pool is installed on each evaluation context via
2894/// [`EvaluationContext::set_resolution_scope`].
2895///
2896/// `external` holds resources fetched from trusted remote servers (the remote
2897/// `resolve()` prefetch, [`remote_fetch::prefetch_external_resources`]); they are
2898/// appended *after* the bundle's own resources so that an in-bundle resource always
2899/// wins over a remotely-fetched copy of the same `Type/id`.
2900fn build_resolution_scope<B: BundleTrait>(
2901    bundle: &B,
2902    external: Vec<helios_fhir::FhirResource>,
2903) -> std::sync::Arc<Vec<helios_fhir::FhirResource>> {
2904    let mut resources: Vec<helios_fhir::FhirResource> = bundle
2905        .entries()
2906        .into_iter()
2907        .map(|resource| resource.to_fhir_resource())
2908        .collect();
2909    resources.extend(external);
2910    std::sync::Arc::new(resources)
2911}
2912
2913// Generic resource filtering
2914fn filter_resources<'a, B: BundleTrait>(
2915    bundle: &'a B,
2916    resource_type: &str,
2917) -> Result<Vec<&'a B::Resource>, SofError> {
2918    Ok(bundle
2919        .entries()
2920        .into_iter()
2921        .filter(|resource| resource.resource_name() == resource_type)
2922        .collect())
2923}
2924
2925// Generic where clause application
2926fn apply_where_clauses<'a, R, W>(
2927    resources: Vec<&'a R>,
2928    where_clauses: Option<&[W]>,
2929    variables: &HashMap<String, EvaluationResult>,
2930    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
2931) -> Result<Vec<&'a R>, SofError>
2932where
2933    R: ResourceTrait,
2934    W: ViewDefinitionWhereTrait,
2935{
2936    if let Some(wheres) = where_clauses {
2937        let mut filtered = Vec::new();
2938
2939        for resource in resources {
2940            let mut include_resource = true;
2941
2942            // All where clauses must evaluate to true for the resource to be included
2943            for where_clause in wheres {
2944                let fhir_resource = resource.to_fhir_resource();
2945                let mut context = EvaluationContext::new(vec![fhir_resource]);
2946                // Expose the whole bundle so `where` clauses can use `resolve()`.
2947                context.set_resolution_scope(std::sync::Arc::clone(resolution_scope));
2948
2949                // Add variables to the context
2950                for (name, value) in variables {
2951                    context.set_variable_result(name, value.clone());
2952                }
2953
2954                let path = where_clause.path().ok_or_else(|| {
2955                    SofError::InvalidViewDefinition("Where clause path is required".to_string())
2956                })?;
2957
2958                match evaluate_expression(path, &context) {
2959                    Ok(result) => {
2960                        // Check if the result can be meaningfully used as a boolean
2961                        if !can_be_coerced_to_boolean(&result) {
2962                            return Err(SofError::InvalidViewDefinition(format!(
2963                                "Where clause path '{}' returns type '{}' which cannot be used as a boolean condition. \
2964                                 Where clauses must return boolean values, collections, or empty results.",
2965                                path,
2966                                result.type_name()
2967                            )));
2968                        }
2969
2970                        // Check if result is truthy (non-empty and not false)
2971                        if !is_truthy(&result) {
2972                            include_resource = false;
2973                            break;
2974                        }
2975                    }
2976                    Err(e) => {
2977                        return Err(SofError::FhirPathError(format!(
2978                            "Error evaluating where clause '{}': {}",
2979                            path, e
2980                        )));
2981                    }
2982                }
2983            }
2984
2985            if include_resource {
2986                filtered.push(resource);
2987            }
2988        }
2989
2990        Ok(filtered)
2991    } else {
2992        Ok(resources)
2993    }
2994}
2995
2996// Removed generate_rows_per_resource_r4 - replaced with new forEach-aware implementation
2997
2998// Removed generate_rows_with_for_each_r4 - replaced with new forEach-aware implementation
2999
3000// Helper functions for FHIRPath result processing
3001fn is_truthy(result: &EvaluationResult) -> bool {
3002    match result {
3003        EvaluationResult::Empty => false,
3004        EvaluationResult::Boolean(b, _, _) => *b,
3005        EvaluationResult::Collection { items, .. } => !items.is_empty(),
3006        _ => true, // Non-empty, non-false values are truthy
3007    }
3008}
3009
3010fn fhirpath_result_to_json_value_collection(result: EvaluationResult) -> Option<serde_json::Value> {
3011    match result {
3012        EvaluationResult::Empty => Some(serde_json::Value::Array(vec![])),
3013        EvaluationResult::Collection { items, .. } => {
3014            // Always return array for collection columns, even if empty
3015            let values: Vec<serde_json::Value> = items
3016                .into_iter()
3017                .filter_map(fhirpath_result_to_json_value)
3018                .collect();
3019            Some(serde_json::Value::Array(values))
3020        }
3021        // For non-collection results in collection columns, wrap in array
3022        single_result => {
3023            if let Some(json_val) = fhirpath_result_to_json_value(single_result) {
3024                Some(serde_json::Value::Array(vec![json_val]))
3025            } else {
3026                Some(serde_json::Value::Array(vec![]))
3027            }
3028        }
3029    }
3030}
3031
3032fn fhirpath_result_to_json_value(result: EvaluationResult) -> Option<serde_json::Value> {
3033    match result {
3034        EvaluationResult::Empty => None,
3035        EvaluationResult::Boolean(b, _, _) => Some(serde_json::Value::Bool(b)),
3036        EvaluationResult::Integer(i, _, _) => {
3037            Some(serde_json::Value::Number(serde_json::Number::from(i)))
3038        }
3039        EvaluationResult::Decimal(d, _, _) => {
3040            // Check if this Decimal represents a whole number
3041            if d.fract().is_zero() {
3042                // Convert to integer if no fractional part
3043                if let Ok(i) = d.to_string().parse::<i64>() {
3044                    Some(serde_json::Value::Number(serde_json::Number::from(i)))
3045                } else {
3046                    // Handle very large numbers as strings
3047                    Some(serde_json::Value::String(d.to_string()))
3048                }
3049            } else {
3050                // Convert Decimal to a float for fractional numbers
3051                if let Ok(f) = d.to_string().parse::<f64>() {
3052                    if let Some(num) = serde_json::Number::from_f64(f) {
3053                        Some(serde_json::Value::Number(num))
3054                    } else {
3055                        Some(serde_json::Value::String(d.to_string()))
3056                    }
3057                } else {
3058                    Some(serde_json::Value::String(d.to_string()))
3059                }
3060            }
3061        }
3062        EvaluationResult::String(s, _, _) => Some(serde_json::Value::String(s)),
3063        EvaluationResult::Date(s, _, _) => Some(serde_json::Value::String(s)),
3064        EvaluationResult::DateTime(s, _, _) => {
3065            // Remove "@" prefix from datetime strings if present
3066            let cleaned = s.strip_prefix("@").unwrap_or(&s);
3067            Some(serde_json::Value::String(cleaned.to_string()))
3068        }
3069        EvaluationResult::Time(s, _, _) => {
3070            // Remove "@T" prefix from time strings if present
3071            let cleaned = s.strip_prefix("@T").unwrap_or(&s);
3072            Some(serde_json::Value::String(cleaned.to_string()))
3073        }
3074        EvaluationResult::Collection { items, .. } => {
3075            if items.len() == 1 {
3076                // Single item collection - unwrap to the item itself
3077                fhirpath_result_to_json_value(items.into_iter().next().unwrap())
3078            } else if items.is_empty() {
3079                None
3080            } else {
3081                // Multiple items - convert to array
3082                let values: Vec<serde_json::Value> = items
3083                    .into_iter()
3084                    .filter_map(fhirpath_result_to_json_value)
3085                    .collect();
3086                Some(serde_json::Value::Array(values))
3087            }
3088        }
3089        EvaluationResult::Object { map, .. } => {
3090            let mut json_map = serde_json::Map::new();
3091            for (k, v) in map {
3092                if let Some(json_val) = fhirpath_result_to_json_value(v) {
3093                    json_map.insert(k, json_val);
3094                }
3095            }
3096            Some(serde_json::Value::Object(json_map))
3097        }
3098        // Handle other result types as strings
3099        _ => Some(serde_json::Value::String(format!("{:?}", result))),
3100    }
3101}
3102
3103fn extract_iteration_items(result: EvaluationResult) -> Vec<EvaluationResult> {
3104    match result {
3105        EvaluationResult::Collection { items, .. } => items,
3106        EvaluationResult::Empty => Vec::new(),
3107        single_item => vec![single_item],
3108    }
3109}
3110
3111// Generic row generation functions
3112
3113fn generate_rows_from_selects<R, S>(
3114    resources: &[&R],
3115    selects: &[S],
3116    variables: &HashMap<String, EvaluationResult>,
3117    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
3118) -> Result<(Vec<String>, Vec<ProcessedRow>), SofError>
3119where
3120    R: ResourceTrait + Sync,
3121    S: ViewDefinitionSelectTrait + Sync,
3122    S::Select: ViewDefinitionSelectTrait,
3123{
3124    // Process resources in parallel
3125    let resource_results: Result<Vec<_>, _> = resources
3126        .par_iter()
3127        .map(|resource| {
3128            // Each thread gets its own local column vector
3129            let mut local_columns = Vec::new();
3130            let resource_rows = generate_rows_for_resource(
3131                *resource,
3132                selects,
3133                &mut local_columns,
3134                variables,
3135                resolution_scope,
3136            )?;
3137            Ok::<(Vec<String>, Vec<ProcessedRow>), SofError>((local_columns, resource_rows))
3138        })
3139        .collect();
3140
3141    // Handle errors from parallel processing
3142    let resource_results = resource_results?;
3143
3144    // Merge columns from all threads (maintaining order is important)
3145    let mut final_columns = Vec::new();
3146    let mut all_rows = Vec::new();
3147
3148    for (local_columns, resource_rows) in resource_results {
3149        // Merge columns, avoiding duplicates
3150        for col in local_columns {
3151            if !final_columns.contains(&col) {
3152                final_columns.push(col);
3153            }
3154        }
3155        all_rows.extend(resource_rows);
3156    }
3157
3158    Ok((final_columns, all_rows))
3159}
3160
3161fn generate_rows_for_resource<R, S>(
3162    resource: &R,
3163    selects: &[S],
3164    all_columns: &mut Vec<String>,
3165    variables: &HashMap<String, EvaluationResult>,
3166    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
3167) -> Result<Vec<ProcessedRow>, SofError>
3168where
3169    R: ResourceTrait,
3170    S: ViewDefinitionSelectTrait,
3171    S::Select: ViewDefinitionSelectTrait,
3172{
3173    let fhir_resource = resource.to_fhir_resource();
3174    let mut context = EvaluationContext::new(vec![fhir_resource]);
3175    // Expose the whole bundle so column/forEach paths can use `resolve()`. `this`
3176    // stays the current resource, so `%resource`/root semantics are unchanged.
3177    context.set_resolution_scope(std::sync::Arc::clone(resolution_scope));
3178
3179    // Add variables to the context
3180    for (name, value) in variables {
3181        context.set_variable_result(name, value.clone());
3182    }
3183
3184    // Generate all possible row combinations for this resource
3185    let row_combinations = generate_row_combinations(&context, selects, all_columns, variables)?;
3186
3187    Ok(row_combinations)
3188}
3189
3190#[derive(Debug, Clone)]
3191struct RowCombination {
3192    values: Vec<Option<serde_json::Value>>,
3193}
3194
3195fn generate_row_combinations<S>(
3196    context: &EvaluationContext,
3197    selects: &[S],
3198    all_columns: &mut Vec<String>,
3199    variables: &HashMap<String, EvaluationResult>,
3200) -> Result<Vec<ProcessedRow>, SofError>
3201where
3202    S: ViewDefinitionSelectTrait,
3203    S::Select: ViewDefinitionSelectTrait,
3204{
3205    // First pass: collect all column names to ensure consistent ordering
3206    collect_all_columns(selects, all_columns)?;
3207
3208    // Second pass: generate all row combinations
3209    let mut row_combinations = vec![RowCombination {
3210        values: vec![None; all_columns.len()],
3211    }];
3212
3213    for select in selects {
3214        row_combinations =
3215            expand_select_combinations(context, select, &row_combinations, all_columns, variables)?;
3216    }
3217
3218    // Convert to ProcessedRow format
3219    Ok(row_combinations
3220        .into_iter()
3221        .map(|combo| ProcessedRow {
3222            values: combo.values,
3223        })
3224        .collect())
3225}
3226
3227fn collect_all_columns<S>(selects: &[S], all_columns: &mut Vec<String>) -> Result<(), SofError>
3228where
3229    S: ViewDefinitionSelectTrait,
3230{
3231    for select in selects {
3232        // Add columns from this select
3233        if let Some(columns) = select.column() {
3234            for col in columns {
3235                if let Some(name) = col.name() {
3236                    if !all_columns.contains(&name.to_string()) {
3237                        all_columns.push(name.to_string());
3238                    }
3239                }
3240            }
3241        }
3242
3243        // Recursively collect from nested selects
3244        if let Some(nested_selects) = select.select() {
3245            collect_all_columns(nested_selects, all_columns)?;
3246        }
3247
3248        // Collect from unionAll
3249        if let Some(union_selects) = select.union_all() {
3250            collect_all_columns(union_selects, all_columns)?;
3251        }
3252    }
3253    Ok(())
3254}
3255
3256fn expand_select_combinations<S>(
3257    context: &EvaluationContext,
3258    select: &S,
3259    existing_combinations: &[RowCombination],
3260    all_columns: &[String],
3261    variables: &HashMap<String, EvaluationResult>,
3262) -> Result<Vec<RowCombination>, SofError>
3263where
3264    S: ViewDefinitionSelectTrait,
3265    S::Select: ViewDefinitionSelectTrait,
3266{
3267    // Handle forEach and forEachOrNull
3268    if let Some(for_each_path) = select.for_each() {
3269        return expand_for_each_combinations(
3270            context,
3271            select,
3272            existing_combinations,
3273            all_columns,
3274            for_each_path,
3275            false,
3276            variables,
3277        );
3278    }
3279
3280    if let Some(for_each_or_null_path) = select.for_each_or_null() {
3281        return expand_for_each_combinations(
3282            context,
3283            select,
3284            existing_combinations,
3285            all_columns,
3286            for_each_or_null_path,
3287            true,
3288            variables,
3289        );
3290    }
3291
3292    // Handle repeat directive for recursive traversal
3293    if let Some(repeat_paths) = select.repeat() {
3294        return expand_repeat_combinations(
3295            context,
3296            select,
3297            existing_combinations,
3298            all_columns,
3299            &repeat_paths,
3300            variables,
3301        );
3302    }
3303
3304    // Handle regular columns (no forEach)
3305    let mut new_combinations = Vec::new();
3306
3307    for existing_combo in existing_combinations {
3308        let mut new_combo = existing_combo.clone();
3309
3310        // Add values from this select's columns
3311        if let Some(columns) = select.column() {
3312            for col in columns {
3313                if let Some(col_name) = col.name() {
3314                    if let Some(col_index) = all_columns.iter().position(|name| name == col_name) {
3315                        let path = col.path().ok_or_else(|| {
3316                            SofError::InvalidViewDefinition("Column path is required".to_string())
3317                        })?;
3318
3319                        match evaluate_expression(path, context) {
3320                            Ok(result) => {
3321                                // Check if this column is marked as a collection
3322                                let is_collection = col.collection().unwrap_or(false);
3323
3324                                new_combo.values[col_index] = if is_collection {
3325                                    fhirpath_result_to_json_value_collection(result)
3326                                } else {
3327                                    fhirpath_result_to_json_value(result)
3328                                };
3329                            }
3330                            Err(e) => {
3331                                return Err(SofError::FhirPathError(format!(
3332                                    "Error evaluating column '{}' with path '{}': {}",
3333                                    col_name, path, e
3334                                )));
3335                            }
3336                        }
3337                    }
3338                }
3339            }
3340        }
3341
3342        new_combinations.push(new_combo);
3343    }
3344
3345    // Handle nested selects
3346    if let Some(nested_selects) = select.select() {
3347        for nested_select in nested_selects {
3348            new_combinations = expand_select_combinations(
3349                context,
3350                nested_select,
3351                &new_combinations,
3352                all_columns,
3353                variables,
3354            )?;
3355        }
3356    }
3357
3358    // Handle unionAll
3359    if let Some(union_selects) = select.union_all() {
3360        let mut union_combinations = Vec::new();
3361
3362        // Process each unionAll select independently, using the combinations that already have
3363        // values from this select's columns and nested selects
3364        for union_select in union_selects {
3365            let select_combinations = expand_select_combinations(
3366                context,
3367                union_select,
3368                &new_combinations,
3369                all_columns,
3370                variables,
3371            )?;
3372            union_combinations.extend(select_combinations);
3373        }
3374
3375        // unionAll replaces new_combinations with the union results
3376        // If no union results, this resource should be filtered out (no rows for this resource)
3377        new_combinations = union_combinations;
3378    }
3379
3380    Ok(new_combinations)
3381}
3382
3383/// Clone the variable map and set `%rowIndex` to `index` for the current iteration element.
3384///
3385/// `forEach`, `forEachOrNull`, and `repeat` each rebind `%rowIndex` to the 0-based position of
3386/// the element they are processing; nested selects and `unionAll` branches without their own
3387/// iteration inherit this value through the cloned map.
3388fn vars_with_row_index(
3389    variables: &HashMap<String, EvaluationResult>,
3390    index: usize,
3391) -> HashMap<String, EvaluationResult> {
3392    let mut item_vars = variables.clone();
3393    item_vars.insert(
3394        "%rowIndex".to_string(),
3395        EvaluationResult::integer(index as i64),
3396    );
3397    item_vars
3398}
3399
3400fn expand_for_each_combinations<S>(
3401    context: &EvaluationContext,
3402    select: &S,
3403    existing_combinations: &[RowCombination],
3404    all_columns: &[String],
3405    for_each_path: &str,
3406    allow_null: bool,
3407    variables: &HashMap<String, EvaluationResult>,
3408) -> Result<Vec<RowCombination>, SofError>
3409where
3410    S: ViewDefinitionSelectTrait,
3411    S::Select: ViewDefinitionSelectTrait,
3412{
3413    // Evaluate the forEach expression to get iteration items
3414    let for_each_result = evaluate_expression(for_each_path, context).map_err(|e| {
3415        SofError::FhirPathError(format!(
3416            "Error evaluating forEach expression '{}': {}",
3417            for_each_path, e
3418        ))
3419    })?;
3420
3421    let iteration_items = extract_iteration_items(for_each_result);
3422
3423    if iteration_items.is_empty() {
3424        if allow_null {
3425            // forEachOrNull: generate a single null row per existing combination. Per the spec,
3426            // this row is evaluated against an empty element with `%rowIndex` = 0, so most columns
3427            // resolve to null while a `%rowIndex` column still yields 0.
3428            let empty_node = EvaluationResult::Object {
3429                map: HashMap::new(),
3430                type_info: None,
3431            };
3432            let item_vars = vars_with_row_index(variables, 0);
3433            let mut new_combinations = Vec::new();
3434            for existing_combo in existing_combinations {
3435                let mut new_combo = existing_combo.clone();
3436
3437                if let Some(columns) = select.column() {
3438                    for col in columns {
3439                        if let Some(col_name) = col.name() {
3440                            if let Some(col_index) =
3441                                all_columns.iter().position(|name| name == col_name)
3442                            {
3443                                let path = col.path().ok_or_else(|| {
3444                                    SofError::InvalidViewDefinition(
3445                                        "Column path is required".to_string(),
3446                                    )
3447                                })?;
3448
3449                                let result = if path == "$this" {
3450                                    empty_node.clone()
3451                                } else {
3452                                    evaluate_path_on_item(
3453                                        path,
3454                                        &empty_node,
3455                                        &item_vars,
3456                                        &context.resources,
3457                                    )?
3458                                };
3459
3460                                let is_collection = col.collection().unwrap_or(false);
3461                                new_combo.values[col_index] = if is_collection {
3462                                    fhirpath_result_to_json_value_collection(result)
3463                                } else {
3464                                    fhirpath_result_to_json_value(result)
3465                                };
3466                            }
3467                        }
3468                    }
3469                }
3470
3471                new_combinations.push(new_combo);
3472            }
3473            return Ok(new_combinations);
3474        } else {
3475            // forEach with empty collection: no rows
3476            return Ok(Vec::new());
3477        }
3478    }
3479
3480    let mut new_combinations = Vec::new();
3481
3482    // For each iteration item, create new combinations
3483    for (idx, item) in iteration_items.iter().enumerate() {
3484        // `%rowIndex` for this element scopes the columns evaluated below.
3485        let item_vars = vars_with_row_index(variables, idx);
3486
3487        for existing_combo in existing_combinations {
3488            let mut new_combo = existing_combo.clone();
3489
3490            // Evaluate columns in the context of the iteration item
3491            if let Some(columns) = select.column() {
3492                for col in columns {
3493                    if let Some(col_name) = col.name() {
3494                        if let Some(col_index) =
3495                            all_columns.iter().position(|name| name == col_name)
3496                        {
3497                            let path = col.path().ok_or_else(|| {
3498                                SofError::InvalidViewDefinition(
3499                                    "Column path is required".to_string(),
3500                                )
3501                            })?;
3502
3503                            // Use the iteration item directly for path evaluation
3504                            let result = if path == "$this" {
3505                                // Special case: $this refers to the current iteration item
3506                                item.clone()
3507                            } else {
3508                                // Evaluate the path on the iteration item
3509                                evaluate_path_on_item(path, item, &item_vars, &context.resources)?
3510                            };
3511
3512                            // Check if this column is marked as a collection
3513                            let is_collection = col.collection().unwrap_or(false);
3514
3515                            new_combo.values[col_index] = if is_collection {
3516                                fhirpath_result_to_json_value_collection(result)
3517                            } else {
3518                                fhirpath_result_to_json_value(result)
3519                            };
3520                        }
3521                    }
3522                }
3523            }
3524
3525            new_combinations.push(new_combo);
3526        }
3527    }
3528
3529    // Handle nested selects with the forEach context
3530    if let Some(nested_selects) = select.select() {
3531        let mut final_combinations = Vec::new();
3532
3533        for (idx, item) in iteration_items.iter().enumerate() {
3534            // `%rowIndex` for this element scopes its own columns and any nested selects.
3535            let item_vars = vars_with_row_index(variables, idx);
3536            let item_context = create_iteration_context(item, &item_vars, &context.resources);
3537
3538            // For each iteration item, we need to start with the combinations that have
3539            // the correct column values for this forEach scope
3540            for existing_combo in existing_combinations {
3541                // Find the combination that corresponds to this iteration item
3542                // by looking at the values we set for columns in this forEach scope
3543                let mut base_combo = existing_combo.clone();
3544
3545                // Update the base combination with column values for this iteration item
3546                if let Some(columns) = select.column() {
3547                    for col in columns {
3548                        if let Some(col_name) = col.name() {
3549                            if let Some(col_index) =
3550                                all_columns.iter().position(|name| name == col_name)
3551                            {
3552                                let path = col.path().ok_or_else(|| {
3553                                    SofError::InvalidViewDefinition(
3554                                        "Column path is required".to_string(),
3555                                    )
3556                                })?;
3557
3558                                let result = if path == "$this" {
3559                                    item.clone()
3560                                } else {
3561                                    evaluate_path_on_item(
3562                                        path,
3563                                        item,
3564                                        &item_vars,
3565                                        &context.resources,
3566                                    )?
3567                                };
3568
3569                                // Check if this column is marked as a collection
3570                                let is_collection = col.collection().unwrap_or(false);
3571
3572                                base_combo.values[col_index] = if is_collection {
3573                                    fhirpath_result_to_json_value_collection(result)
3574                                } else {
3575                                    fhirpath_result_to_json_value(result)
3576                                };
3577                            }
3578                        }
3579                    }
3580                }
3581
3582                // Start with this base combination for nested processing
3583                let mut item_combinations = vec![base_combo];
3584
3585                // Process nested selects
3586                for nested_select in nested_selects {
3587                    item_combinations = expand_select_combinations(
3588                        &item_context,
3589                        nested_select,
3590                        &item_combinations,
3591                        all_columns,
3592                        &item_vars,
3593                    )?;
3594                }
3595
3596                final_combinations.extend(item_combinations);
3597            }
3598        }
3599
3600        new_combinations = final_combinations;
3601    }
3602
3603    // Handle unionAll within forEach context
3604    if let Some(union_selects) = select.union_all() {
3605        let mut union_combinations = Vec::new();
3606
3607        for (idx, item) in iteration_items.iter().enumerate() {
3608            // `%rowIndex` for this element is inherited by every unionAll branch that does not
3609            // introduce its own `forEach`.
3610            let item_vars = vars_with_row_index(variables, idx);
3611            let item_context = create_iteration_context(item, &item_vars, &context.resources);
3612
3613            // For each iteration item, process all unionAll selects
3614            for existing_combo in existing_combinations {
3615                let mut base_combo = existing_combo.clone();
3616
3617                // Update the base combination with column values for this iteration item
3618                if let Some(columns) = select.column() {
3619                    for col in columns {
3620                        if let Some(col_name) = col.name() {
3621                            if let Some(col_index) =
3622                                all_columns.iter().position(|name| name == col_name)
3623                            {
3624                                let path = col.path().ok_or_else(|| {
3625                                    SofError::InvalidViewDefinition(
3626                                        "Column path is required".to_string(),
3627                                    )
3628                                })?;
3629
3630                                let result = if path == "$this" {
3631                                    item.clone()
3632                                } else {
3633                                    evaluate_path_on_item(
3634                                        path,
3635                                        item,
3636                                        &item_vars,
3637                                        &context.resources,
3638                                    )?
3639                                };
3640
3641                                // Check if this column is marked as a collection
3642                                let is_collection = col.collection().unwrap_or(false);
3643
3644                                base_combo.values[col_index] = if is_collection {
3645                                    fhirpath_result_to_json_value_collection(result)
3646                                } else {
3647                                    fhirpath_result_to_json_value(result)
3648                                };
3649                            }
3650                        }
3651                    }
3652                }
3653
3654                // Also evaluate columns from nested selects and add them to base_combo
3655                if let Some(nested_selects) = select.select() {
3656                    for nested_select in nested_selects {
3657                        if let Some(nested_columns) = nested_select.column() {
3658                            for col in nested_columns {
3659                                if let Some(col_name) = col.name() {
3660                                    if let Some(col_index) =
3661                                        all_columns.iter().position(|name| name == col_name)
3662                                    {
3663                                        let path = col.path().ok_or_else(|| {
3664                                            SofError::InvalidViewDefinition(
3665                                                "Column path is required".to_string(),
3666                                            )
3667                                        })?;
3668
3669                                        let result = if path == "$this" {
3670                                            item.clone()
3671                                        } else {
3672                                            evaluate_path_on_item(
3673                                                path,
3674                                                item,
3675                                                &item_vars,
3676                                                &context.resources,
3677                                            )?
3678                                        };
3679
3680                                        // Check if this column is marked as a collection
3681                                        let is_collection = col.collection().unwrap_or(false);
3682
3683                                        base_combo.values[col_index] = if is_collection {
3684                                            fhirpath_result_to_json_value_collection(result)
3685                                        } else {
3686                                            fhirpath_result_to_json_value(result)
3687                                        };
3688                                    }
3689                                }
3690                            }
3691                        }
3692                    }
3693                }
3694
3695                // Process each unionAll select independently for this iteration item
3696                for union_select in union_selects {
3697                    let mut select_combinations = vec![base_combo.clone()];
3698                    select_combinations = expand_select_combinations(
3699                        &item_context,
3700                        union_select,
3701                        &select_combinations,
3702                        all_columns,
3703                        &item_vars,
3704                    )?;
3705                    union_combinations.extend(select_combinations);
3706                }
3707            }
3708        }
3709
3710        // unionAll replaces new_combinations with the union results
3711        // If no union results, filter out this resource (no rows for this resource)
3712        new_combinations = union_combinations;
3713    }
3714
3715    Ok(new_combinations)
3716}
3717
3718/// Flatten a `repeat` recursive traversal into a pre-order list of descendant nodes.
3719///
3720/// Mirrors the reference implementation's `recursiveTraverse`: starting from `context`'s node,
3721/// each repeat path is evaluated, and for every resulting object child the child is appended and
3722/// then traversed in turn (the starting node itself is never included). Non-object results are
3723/// ignored, matching the reference's `typeof childNode === 'object'` guard. The resulting order is
3724/// what `%rowIndex` enumerates over.
3725fn collect_repeat_nodes(
3726    context: &EvaluationContext,
3727    repeat_paths: &[&str],
3728    variables: &HashMap<String, EvaluationResult>,
3729    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
3730    out: &mut Vec<EvaluationResult>,
3731) -> Result<(), SofError> {
3732    for repeat_path in repeat_paths {
3733        let repeat_result = evaluate_expression(repeat_path, context).map_err(|e| {
3734            SofError::FhirPathError(format!(
3735                "Error evaluating repeat expression '{}': {}",
3736                repeat_path, e
3737            ))
3738        })?;
3739
3740        for child_item in extract_iteration_items(repeat_result) {
3741            // Only object nodes participate in the traversal (matches the reference impl).
3742            if !matches!(child_item, EvaluationResult::Object { .. }) {
3743                continue;
3744            }
3745            let child_context = create_iteration_context(&child_item, variables, resolution_scope);
3746            out.push(child_item);
3747            collect_repeat_nodes(
3748                &child_context,
3749                repeat_paths,
3750                variables,
3751                resolution_scope,
3752                out,
3753            )?;
3754        }
3755    }
3756    Ok(())
3757}
3758
3759fn expand_repeat_combinations<S>(
3760    context: &EvaluationContext,
3761    select: &S,
3762    existing_combinations: &[RowCombination],
3763    all_columns: &[String],
3764    repeat_paths: &[&str],
3765    variables: &HashMap<String, EvaluationResult>,
3766) -> Result<Vec<RowCombination>, SofError>
3767where
3768    S: ViewDefinitionSelectTrait,
3769    S::Select: ViewDefinitionSelectTrait,
3770{
3771    // The repeat directive performs recursive traversal of the elements reachable via the repeat
3772    // paths. We first flatten that traversal into a pre-order node list (see `collect_repeat_nodes`)
3773    // so that each emitted node can be assigned a monotonic `%rowIndex` matching the reference
3774    // implementation. The traversal is independent of `existing_combinations`, so it is collected
3775    // once and applied to each incoming combination.
3776    //
3777    // Note: Unlike forEach, repeat does NOT process the current level's columns
3778    // - it ONLY processes elements found via the repeat paths
3779    let mut nodes = Vec::new();
3780    collect_repeat_nodes(
3781        context,
3782        repeat_paths,
3783        variables,
3784        &context.resources,
3785        &mut nodes,
3786    )?;
3787
3788    let mut all_combinations = Vec::new();
3789
3790    // Process each existing combination, emitting one row per traversed node (cross product).
3791    for existing_combo in existing_combinations {
3792        for (idx, node) in nodes.iter().enumerate() {
3793            // Each traversed node gets its own `%rowIndex` (its position in the flattened list).
3794            let item_vars = vars_with_row_index(variables, idx);
3795            let node_context = create_iteration_context(node, &item_vars, &context.resources);
3796
3797            // Create a combination for this node with the repeat level's columns
3798            let mut node_combo = existing_combo.clone();
3799
3800            if let Some(columns) = select.column() {
3801                for col in columns {
3802                    if let Some(col_name) = col.name() {
3803                        if let Some(col_index) =
3804                            all_columns.iter().position(|name| name == col_name)
3805                        {
3806                            let path = col.path().ok_or_else(|| {
3807                                SofError::InvalidViewDefinition(
3808                                    "Column path is required".to_string(),
3809                                )
3810                            })?;
3811
3812                            // Evaluate the path on the traversed node
3813                            let result = if path == "$this" {
3814                                node.clone()
3815                            } else {
3816                                evaluate_path_on_item(path, node, &item_vars, &context.resources)?
3817                            };
3818
3819                            let is_collection = col.collection().unwrap_or(false);
3820                            node_combo.values[col_index] = if is_collection {
3821                                fhirpath_result_to_json_value_collection(result)
3822                            } else {
3823                                fhirpath_result_to_json_value(result)
3824                            };
3825                        }
3826                    }
3827                }
3828            }
3829
3830            // Start with the node combination we just created
3831            let mut node_combinations = vec![node_combo];
3832
3833            // Process nested selects (like forEach/forEachOrNull) in the node's context
3834            if let Some(nested_selects) = select.select() {
3835                for nested_select in nested_selects {
3836                    node_combinations = expand_select_combinations(
3837                        &node_context,
3838                        nested_select,
3839                        &node_combinations,
3840                        all_columns,
3841                        &item_vars,
3842                    )?;
3843                }
3844            }
3845
3846            // Apply unionAll branches in the node's context
3847            if let Some(union_selects) = select.union_all() {
3848                let mut union_combinations = Vec::new();
3849                for combo in &node_combinations {
3850                    for union_select in union_selects {
3851                        let select_combinations = expand_select_combinations(
3852                            &node_context,
3853                            union_select,
3854                            std::slice::from_ref(combo),
3855                            all_columns,
3856                            &item_vars,
3857                        )?;
3858                        union_combinations.extend(select_combinations);
3859                    }
3860                }
3861                node_combinations = union_combinations;
3862            }
3863
3864            // Add the processed combinations to our results
3865            // (these may have been filtered by forEach, which is correct)
3866            all_combinations.extend(node_combinations);
3867        }
3868    }
3869
3870    Ok(all_combinations)
3871}
3872
3873// Generic helper functions
3874fn evaluate_path_on_item(
3875    path: &str,
3876    item: &EvaluationResult,
3877    variables: &HashMap<String, EvaluationResult>,
3878    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
3879) -> Result<EvaluationResult, SofError> {
3880    // Create a temporary context with the iteration item as the root resource
3881    let mut temp_context = match item {
3882        EvaluationResult::Object { .. } => {
3883            // Convert the iteration item to a resource-like structure for FHIRPath evaluation
3884            // For simplicity, we'll create a basic context where the item is available for evaluation
3885            let mut context = EvaluationContext::new(vec![]);
3886            context.this = Some(item.clone());
3887            context
3888        }
3889        _ => EvaluationContext::new(vec![]),
3890    };
3891    // Carry the bundle-wide resolution pool so chained `resolve()` calls (e.g.
3892    // `forEach: list.resolve()` then a column that resolves a nested reference)
3893    // can still reach sibling resources from this fresh, item-rooted context.
3894    temp_context.set_resolution_scope(std::sync::Arc::clone(resolution_scope));
3895
3896    // Add variables to the temporary context
3897    for (name, value) in variables {
3898        temp_context.set_variable_result(name, value.clone());
3899    }
3900
3901    // Evaluate the FHIRPath expression in the context of the iteration item
3902    match evaluate_expression(path, &temp_context) {
3903        Ok(result) => Ok(result),
3904        Err(_e) => {
3905            // If FHIRPath evaluation fails, try simple property access as fallback
3906            match item {
3907                EvaluationResult::Object { map, .. } => {
3908                    if let Some(value) = map.get(path) {
3909                        Ok(value.clone())
3910                    } else {
3911                        Ok(EvaluationResult::Empty)
3912                    }
3913                }
3914                _ => Ok(EvaluationResult::Empty),
3915            }
3916        }
3917    }
3918}
3919
3920fn create_iteration_context(
3921    item: &EvaluationResult,
3922    variables: &HashMap<String, EvaluationResult>,
3923    resolution_scope: &std::sync::Arc<Vec<helios_fhir::FhirResource>>,
3924) -> EvaluationContext {
3925    // Create a new context with the iteration item as the root
3926    let mut context = EvaluationContext::new(vec![]);
3927    context.this = Some(item.clone());
3928    // Keep the bundle-wide resolution pool available to nested selects/columns
3929    // evaluated against this iteration item, so `resolve()` still works here.
3930    context.set_resolution_scope(std::sync::Arc::clone(resolution_scope));
3931
3932    // Preserve variables from the parent context
3933    for (name, value) in variables {
3934        context.set_variable_result(name, value.clone());
3935    }
3936
3937    context
3938}
3939
3940/// Filter a bundle's resources by their lastUpdated metadata
3941fn filter_bundle_by_since(bundle: SofBundle, since: DateTime<Utc>) -> Result<SofBundle, SofError> {
3942    match bundle {
3943        #[cfg(feature = "R4")]
3944        SofBundle::R4(mut b) => {
3945            if let Some(entries) = b.entry.as_mut() {
3946                entries.retain(|entry| {
3947                    entry
3948                        .resource
3949                        .as_ref()
3950                        .and_then(|r| r.get_last_updated())
3951                        .map(|last_updated| last_updated > since)
3952                        .unwrap_or(false)
3953                });
3954            }
3955            Ok(SofBundle::R4(b))
3956        }
3957        #[cfg(feature = "R4B")]
3958        SofBundle::R4B(mut b) => {
3959            if let Some(entries) = b.entry.as_mut() {
3960                entries.retain(|entry| {
3961                    entry
3962                        .resource
3963                        .as_ref()
3964                        .and_then(|r| r.get_last_updated())
3965                        .map(|last_updated| last_updated > since)
3966                        .unwrap_or(false)
3967                });
3968            }
3969            Ok(SofBundle::R4B(b))
3970        }
3971        #[cfg(feature = "R5")]
3972        SofBundle::R5(mut b) => {
3973            if let Some(entries) = b.entry.as_mut() {
3974                entries.retain(|entry| {
3975                    entry
3976                        .resource
3977                        .as_ref()
3978                        .and_then(|r| r.get_last_updated())
3979                        .map(|last_updated| last_updated > since)
3980                        .unwrap_or(false)
3981                });
3982            }
3983            Ok(SofBundle::R5(b))
3984        }
3985        #[cfg(feature = "R6")]
3986        SofBundle::R6(mut b) => {
3987            if let Some(entries) = b.entry.as_mut() {
3988                entries.retain(|entry| {
3989                    entry
3990                        .resource
3991                        .as_ref()
3992                        .and_then(|r| r.get_last_updated())
3993                        .map(|last_updated| last_updated > since)
3994                        .unwrap_or(false)
3995                });
3996            }
3997            Ok(SofBundle::R6(b))
3998        }
3999    }
4000}
4001
4002/// Apply pagination to processed results
4003fn apply_pagination_to_result(
4004    mut result: ProcessedResult,
4005    limit: Option<usize>,
4006    page: Option<usize>,
4007) -> Result<ProcessedResult, SofError> {
4008    if let Some(limit) = limit {
4009        let page_num = page.unwrap_or(1);
4010        if page_num == 0 {
4011            return Err(SofError::InvalidViewDefinition(
4012                "Page number must be greater than 0".to_string(),
4013            ));
4014        }
4015
4016        let start_index = (page_num - 1) * limit;
4017        if start_index >= result.rows.len() {
4018            // Return empty result if page is beyond data
4019            result.rows.clear();
4020        } else {
4021            let end_index = std::cmp::min(start_index + limit, result.rows.len());
4022            result.rows = result.rows[start_index..end_index].to_vec();
4023        }
4024    }
4025
4026    Ok(result)
4027}
4028
4029/// Renders a [`ProcessedResult`] to bytes in the requested [`ContentType`].
4030///
4031/// Dispatches to [`format_csv`], [`format_json`], [`format_ndjson`], or
4032/// [`format_parquet`] based on `content_type`. Callers outside this crate
4033/// (REST handlers, pysof, sof-server) use this entry point so output shape is
4034/// consistent across consumers.
4035pub fn format_output(
4036    result: ProcessedResult,
4037    content_type: ContentType,
4038    parquet_options: Option<&ParquetOptions>,
4039) -> Result<Vec<u8>, SofError> {
4040    match content_type {
4041        ContentType::Csv | ContentType::CsvWithHeader => {
4042            format_csv(result, content_type == ContentType::CsvWithHeader)
4043        }
4044        ContentType::Json => format_json(result),
4045        ContentType::NdJson => format_ndjson(result),
4046        ContentType::Parquet => format_parquet(result, parquet_options),
4047        ContentType::ArrowIpc => format_arrow_ipc(result),
4048    }
4049}
4050
4051/// Builds a [`ProcessedResult`] from a stream of flat JSON-object rows.
4052///
4053/// Used by callers that receive rows as `serde_json::Value` (e.g. the REST
4054/// SoF runner streams) and want to feed them through the shared output
4055/// formatters. Column order is taken from the first row's key order;
4056/// subsequent rows fill in missing keys as `None`.
4057pub fn rows_to_processed_result(rows: Vec<serde_json::Value>) -> ProcessedResult {
4058    let columns: Vec<String> = match rows.first() {
4059        Some(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
4060        _ => Vec::new(),
4061    };
4062    let processed_rows = rows
4063        .iter()
4064        .map(|row| {
4065            let values = columns
4066                .iter()
4067                .map(|col| match row {
4068                    serde_json::Value::Object(map) => map.get(col).cloned(),
4069                    _ => None,
4070                })
4071                .collect();
4072            ProcessedRow { values }
4073        })
4074        .collect();
4075    ProcessedResult {
4076        columns,
4077        rows: processed_rows,
4078    }
4079}
4080
4081/// Encodes a [`ProcessedResult`] as CSV bytes via the `csv` crate (RFC 4180).
4082///
4083/// String values are emitted raw; non-string values are JSON-serialised. The
4084/// underlying writer handles quoting for fields containing `,`, `"`, or
4085/// newlines, so callers do not need to escape.
4086pub fn format_csv(result: ProcessedResult, include_header: bool) -> Result<Vec<u8>, SofError> {
4087    let mut wtr = csv::Writer::from_writer(vec![]);
4088
4089    if include_header {
4090        wtr.write_record(&result.columns)?;
4091    }
4092
4093    for row in result.rows {
4094        let record: Vec<String> = row
4095            .values
4096            .iter()
4097            .map(|v| match v {
4098                Some(val) => {
4099                    // For string values, extract the raw string instead of JSON serializing
4100                    if let serde_json::Value::String(s) = val {
4101                        s.clone()
4102                    } else {
4103                        // For non-string values, use JSON serialization
4104                        serde_json::to_string(val).unwrap_or_default()
4105                    }
4106                }
4107                None => String::new(),
4108            })
4109            .collect();
4110        wtr.write_record(&record)?;
4111    }
4112
4113    wtr.into_inner()
4114        .map_err(|e| SofError::CsvWriterError(e.to_string()))
4115}
4116
4117/// Encodes a [`ProcessedResult`] as a pretty-printed JSON array of row
4118/// objects. Missing column values are emitted as `null`.
4119pub fn format_json(result: ProcessedResult) -> Result<Vec<u8>, SofError> {
4120    let mut output = Vec::new();
4121
4122    for row in result.rows {
4123        let mut row_obj = serde_json::Map::new();
4124        for (i, column) in result.columns.iter().enumerate() {
4125            let value = row
4126                .values
4127                .get(i)
4128                .and_then(|v| v.as_ref())
4129                .cloned()
4130                .unwrap_or(serde_json::Value::Null);
4131            row_obj.insert(column.clone(), value);
4132        }
4133        output.push(serde_json::Value::Object(row_obj));
4134    }
4135
4136    Ok(serde_json::to_vec_pretty(&output)?)
4137}
4138
4139/// Encodes a [`ProcessedResult`] as newline-delimited JSON. One row per
4140/// line; missing column values are emitted as `null`.
4141pub fn format_ndjson(result: ProcessedResult) -> Result<Vec<u8>, SofError> {
4142    let mut output = Vec::new();
4143
4144    for row in result.rows {
4145        let mut row_obj = serde_json::Map::new();
4146        for (i, column) in result.columns.iter().enumerate() {
4147            let value = row
4148                .values
4149                .get(i)
4150                .and_then(|v| v.as_ref())
4151                .cloned()
4152                .unwrap_or(serde_json::Value::Null);
4153            row_obj.insert(column.clone(), value);
4154        }
4155        let line = serde_json::to_string(&serde_json::Value::Object(row_obj))?;
4156        output.extend_from_slice(line.as_bytes());
4157        output.push(b'\n');
4158    }
4159
4160    Ok(output)
4161}
4162
4163/// Rows-per-batch for the columnar writers, estimated from sampled row sizes
4164/// and clamped to keep memory bounded. Shared by Parquet and Arrow IPC so the
4165/// two outputs batch identically.
4166fn estimate_rows_per_batch(rows: &[ProcessedRow], target_batch_size_bytes: usize) -> usize {
4167    const TARGET_ROWS_PER_BATCH: usize = 100_000; // Default batch size
4168    const MAX_ROWS_PER_BATCH: usize = 500_000; // Maximum to prevent memory issues
4169
4170    // Estimate average row size from first 100 rows
4171    let sample_size = std::cmp::min(100, rows.len());
4172    let mut estimated_row_size = 100; // Default estimate in bytes
4173
4174    if sample_size > 0 {
4175        let sample_json_size: usize = rows[..sample_size]
4176            .iter()
4177            .map(|row| {
4178                row.values
4179                    .iter()
4180                    .filter_map(|v| v.as_ref())
4181                    .map(|v| v.to_string().len())
4182                    .sum::<usize>()
4183            })
4184            .sum();
4185        estimated_row_size = (sample_json_size / sample_size).max(50);
4186    }
4187
4188    (target_batch_size_bytes / estimated_row_size).clamp(TARGET_ROWS_PER_BATCH, MAX_ROWS_PER_BATCH)
4189}
4190
4191/// Streams `result`'s rows to `write` as Arrow [`RecordBatch`]es of at most
4192/// `batch_size` rows. The shared batch builder behind every columnar output
4193/// (Parquet, Arrow IPC) and [`run_view_definition_record_batches`].
4194fn for_each_record_batch<F>(
4195    schema_ref: &std::sync::Arc<arrow::datatypes::Schema>,
4196    result: &ProcessedResult,
4197    batch_size: usize,
4198    mut write: F,
4199) -> Result<(), SofError>
4200where
4201    F: FnMut(arrow::record_batch::RecordBatch) -> Result<(), SofError>,
4202{
4203    use arrow::record_batch::RecordBatch;
4204
4205    let mut row_offset = 0;
4206    while row_offset < result.rows.len() {
4207        let batch_end = (row_offset + batch_size).min(result.rows.len());
4208        let batch_rows = &result.rows[row_offset..batch_end];
4209
4210        let batch_arrays =
4211            parquet_schema::process_to_arrow_arrays(schema_ref, &result.columns, batch_rows)?;
4212
4213        let batch = RecordBatch::try_new(schema_ref.clone(), batch_arrays).map_err(|e| {
4214            SofError::ArrowConversionError(format!(
4215                "Failed to create RecordBatch for rows {}-{}: {}",
4216                row_offset, batch_end, e
4217            ))
4218        })?;
4219
4220        write(batch)?;
4221        row_offset = batch_end;
4222    }
4223    Ok(())
4224}
4225
4226/// Runs a ViewDefinition and returns the result as Arrow record batches with
4227/// their schema — the engine-side seam for columnar consumers (Parquet, Arrow
4228/// IPC, and external bindings such as pysof).
4229///
4230/// Schema inference and type mapping are identical to the Parquet output, so
4231/// every columnar representation of the same result agrees.
4232pub fn run_view_definition_record_batches(
4233    view_definition: SofViewDefinition,
4234    bundle: SofBundle,
4235) -> Result<
4236    (
4237        std::sync::Arc<arrow::datatypes::Schema>,
4238        Vec<arrow::record_batch::RecordBatch>,
4239    ),
4240    SofError,
4241> {
4242    let result = process_view_definition(view_definition, bundle)?;
4243    let schema = parquet_schema::create_arrow_schema(&result.columns, &result.rows)?;
4244    let schema_ref = std::sync::Arc::new(schema);
4245    let batch_size = estimate_rows_per_batch(
4246        &result.rows,
4247        (ParquetOptions::default().row_group_size_mb as usize) * 1024 * 1024,
4248    );
4249    let mut batches = Vec::new();
4250    for_each_record_batch(&schema_ref, &result, batch_size, |batch| {
4251        batches.push(batch);
4252        Ok(())
4253    })?;
4254    Ok((schema_ref, batches))
4255}
4256
4257/// Encodes a [`ProcessedResult`] as an Arrow IPC stream
4258/// (`application/vnd.apache.arrow.stream`).
4259///
4260/// Schema inference and type mapping match [`format_parquet`] exactly, so the
4261/// columnar outputs always agree. Batches are written incrementally and the
4262/// stream carries its schema in-band, so consumers (DuckDB, pyarrow, polars,
4263/// pandas) ingest live query results without any client-side parsing.
4264pub fn format_arrow_ipc(result: ProcessedResult) -> Result<Vec<u8>, SofError> {
4265    use arrow::ipc::writer::StreamWriter;
4266
4267    let schema = parquet_schema::create_arrow_schema(&result.columns, &result.rows)?;
4268    let schema_ref = std::sync::Arc::new(schema);
4269    let batch_size = estimate_rows_per_batch(
4270        &result.rows,
4271        (ParquetOptions::default().row_group_size_mb as usize) * 1024 * 1024,
4272    );
4273
4274    let mut buffer = Vec::new();
4275    {
4276        let mut writer = StreamWriter::try_new(&mut buffer, schema_ref.as_ref()).map_err(|e| {
4277            SofError::ArrowConversionError(format!("Failed to create Arrow IPC writer: {}", e))
4278        })?;
4279        for_each_record_batch(&schema_ref, &result, batch_size, |batch| {
4280            writer.write(&batch).map_err(|e| {
4281                SofError::ArrowConversionError(format!("Failed to write Arrow IPC batch: {}", e))
4282            })
4283        })?;
4284        writer.finish().map_err(|e| {
4285            SofError::ArrowConversionError(format!("Failed to finish Arrow IPC stream: {}", e))
4286        })?;
4287    }
4288
4289    Ok(buffer)
4290}
4291
4292/// Encodes a [`ProcessedResult`] as a single Parquet file in memory.
4293///
4294/// Schema is inferred from `result.columns` and the row values; type mapping
4295/// follows Pathling conventions (boolean→BOOLEAN, string/code/uri→UTF8,
4296/// integer→INT32, decimal→FLOAT64, dateTime/date→UTF8). Use
4297/// [`format_parquet_multi_file`] when the output needs to be split across
4298/// files by size.
4299pub fn format_parquet(
4300    result: ProcessedResult,
4301    options: Option<&ParquetOptions>,
4302) -> Result<Vec<u8>, SofError> {
4303    use parquet::arrow::ArrowWriter;
4304    use parquet::basic::Compression;
4305    use parquet::file::properties::WriterProperties;
4306    use std::io::Cursor;
4307
4308    // Create Arrow schema from columns and sample data
4309    let schema = parquet_schema::create_arrow_schema(&result.columns, &result.rows)?;
4310    let schema_ref = std::sync::Arc::new(schema.clone());
4311
4312    // Get configuration from options or use defaults
4313    let parquet_opts = options.cloned().unwrap_or_default();
4314
4315    // Calculate optimal batch size based on row count and estimated row size
4316    let target_row_group_size_bytes = (parquet_opts.row_group_size_mb as usize) * 1024 * 1024;
4317    let target_page_size_bytes = (parquet_opts.page_size_kb as usize) * 1024;
4318    let optimal_batch_size = estimate_rows_per_batch(&result.rows, target_row_group_size_bytes);
4319
4320    // Parse compression algorithm
4321    use parquet::basic::BrotliLevel;
4322    use parquet::basic::GzipLevel;
4323    use parquet::basic::ZstdLevel;
4324
4325    let compression = match parquet_opts.compression.as_str() {
4326        "none" => Compression::UNCOMPRESSED,
4327        "gzip" => Compression::GZIP(GzipLevel::default()),
4328        "lz4" => Compression::LZ4,
4329        "brotli" => Compression::BROTLI(BrotliLevel::default()),
4330        "zstd" => Compression::ZSTD(ZstdLevel::default()),
4331        _ => Compression::SNAPPY, // Default to snappy
4332    };
4333
4334    // Set up writer properties with optimized settings
4335    let props = WriterProperties::builder()
4336        .set_compression(compression)
4337        .set_max_row_group_size(target_row_group_size_bytes)
4338        .set_data_page_row_count_limit(20_000) // Optimal for predicate pushdown
4339        .set_data_page_size_limit(target_page_size_bytes)
4340        .set_write_batch_size(8192) // Control write granularity
4341        .build();
4342
4343    // Write to memory buffer
4344    let mut buffer = Vec::new();
4345    let mut cursor = Cursor::new(&mut buffer);
4346    let mut writer =
4347        ArrowWriter::try_new(&mut cursor, schema_ref.clone(), Some(props)).map_err(|e| {
4348            SofError::ParquetConversionError(format!("Failed to create Parquet writer: {}", e))
4349        })?;
4350
4351    // Process data in batches to handle large datasets efficiently
4352    for_each_record_batch(&schema_ref, &result, optimal_batch_size, |batch| {
4353        writer.write(&batch).map_err(|e| {
4354            SofError::ParquetConversionError(format!("Failed to write RecordBatch: {}", e))
4355        })
4356    })?;
4357
4358    writer.close().map_err(|e| {
4359        SofError::ParquetConversionError(format!("Failed to close Parquet writer: {}", e))
4360    })?;
4361
4362    Ok(buffer)
4363}
4364
4365/// Format Parquet data with automatic file splitting when size exceeds limit
4366pub fn format_parquet_multi_file(
4367    result: ProcessedResult,
4368    options: Option<&ParquetOptions>,
4369    max_file_size_bytes: usize,
4370) -> Result<Vec<Vec<u8>>, SofError> {
4371    use arrow::record_batch::RecordBatch;
4372    use parquet::arrow::ArrowWriter;
4373    use parquet::basic::Compression;
4374    use parquet::file::properties::WriterProperties;
4375    use std::io::Cursor;
4376
4377    // Create Arrow schema from columns and sample data
4378    let schema = parquet_schema::create_arrow_schema(&result.columns, &result.rows)?;
4379    let schema_ref = std::sync::Arc::new(schema.clone());
4380
4381    // Get configuration from options or use defaults
4382    let parquet_opts = options.cloned().unwrap_or_default();
4383
4384    // Calculate optimal batch size
4385    let target_row_group_size_bytes = (parquet_opts.row_group_size_mb as usize) * 1024 * 1024;
4386    let target_page_size_bytes = (parquet_opts.page_size_kb as usize) * 1024;
4387    const TARGET_ROWS_PER_BATCH: usize = 100_000;
4388    const MAX_ROWS_PER_BATCH: usize = 500_000;
4389
4390    // Estimate average row size
4391    let sample_size = std::cmp::min(100, result.rows.len());
4392    let mut estimated_row_size = 100;
4393
4394    if sample_size > 0 {
4395        let sample_json_size: usize = result.rows[..sample_size]
4396            .iter()
4397            .map(|row| {
4398                row.values
4399                    .iter()
4400                    .filter_map(|v| v.as_ref())
4401                    .map(|v| v.to_string().len())
4402                    .sum::<usize>()
4403            })
4404            .sum();
4405        estimated_row_size = (sample_json_size / sample_size).max(50);
4406    }
4407
4408    let optimal_batch_size = (target_row_group_size_bytes / estimated_row_size)
4409        .clamp(TARGET_ROWS_PER_BATCH, MAX_ROWS_PER_BATCH);
4410
4411    // Parse compression algorithm
4412    use parquet::basic::BrotliLevel;
4413    use parquet::basic::GzipLevel;
4414    use parquet::basic::ZstdLevel;
4415
4416    let compression = match parquet_opts.compression.as_str() {
4417        "none" => Compression::UNCOMPRESSED,
4418        "gzip" => Compression::GZIP(GzipLevel::default()),
4419        "lz4" => Compression::LZ4,
4420        "brotli" => Compression::BROTLI(BrotliLevel::default()),
4421        "zstd" => Compression::ZSTD(ZstdLevel::default()),
4422        _ => Compression::SNAPPY,
4423    };
4424
4425    // Set up writer properties
4426    let props = WriterProperties::builder()
4427        .set_compression(compression)
4428        .set_max_row_group_size(target_row_group_size_bytes)
4429        .set_data_page_row_count_limit(20_000)
4430        .set_data_page_size_limit(target_page_size_bytes)
4431        .set_write_batch_size(8192)
4432        .build();
4433
4434    let mut file_buffers = Vec::new();
4435    let mut current_buffer = Vec::new();
4436    let mut current_cursor = Cursor::new(&mut current_buffer);
4437    let mut current_writer =
4438        ArrowWriter::try_new(&mut current_cursor, schema_ref.clone(), Some(props.clone()))
4439            .map_err(|e| {
4440                SofError::ParquetConversionError(format!("Failed to create Parquet writer: {}", e))
4441            })?;
4442
4443    let mut row_offset = 0;
4444    let mut _current_file_rows = 0;
4445
4446    while row_offset < result.rows.len() {
4447        let batch_end = (row_offset + optimal_batch_size).min(result.rows.len());
4448        let batch_rows = &result.rows[row_offset..batch_end];
4449
4450        // Convert batch to Arrow arrays
4451        let batch_arrays =
4452            parquet_schema::process_to_arrow_arrays(&schema, &result.columns, batch_rows)?;
4453
4454        // Create RecordBatch
4455        let batch = RecordBatch::try_new(schema_ref.clone(), batch_arrays).map_err(|e| {
4456            SofError::ParquetConversionError(format!(
4457                "Failed to create RecordBatch for rows {}-{}: {}",
4458                row_offset, batch_end, e
4459            ))
4460        })?;
4461
4462        // Write batch
4463        current_writer.write(&batch).map_err(|e| {
4464            SofError::ParquetConversionError(format!(
4465                "Failed to write RecordBatch for rows {}-{}: {}",
4466                row_offset, batch_end, e
4467            ))
4468        })?;
4469
4470        _current_file_rows += batch_end - row_offset;
4471        row_offset = batch_end;
4472
4473        // Check if we should start a new file
4474        // Get actual size of current buffer by flushing the writer
4475        let current_size = current_writer.bytes_written();
4476
4477        if current_size >= max_file_size_bytes && row_offset < result.rows.len() {
4478            // Close current file
4479            current_writer.close().map_err(|e| {
4480                SofError::ParquetConversionError(format!("Failed to close Parquet writer: {}", e))
4481            })?;
4482
4483            // Save the buffer
4484            file_buffers.push(current_buffer);
4485
4486            // Start new file
4487            current_buffer = Vec::new();
4488            current_cursor = Cursor::new(&mut current_buffer);
4489            current_writer =
4490                ArrowWriter::try_new(&mut current_cursor, schema_ref.clone(), Some(props.clone()))
4491                    .map_err(|e| {
4492                        SofError::ParquetConversionError(format!(
4493                            "Failed to create new Parquet writer: {}",
4494                            e
4495                        ))
4496                    })?;
4497            _current_file_rows = 0;
4498        }
4499    }
4500
4501    // Close the final writer
4502    current_writer.close().map_err(|e| {
4503        SofError::ParquetConversionError(format!("Failed to close final Parquet writer: {}", e))
4504    })?;
4505
4506    file_buffers.push(current_buffer);
4507
4508    Ok(file_buffers)
4509}
4510
4511#[cfg(test)]
4512mod tests {
4513    use super::*;
4514
4515    /// A typed R4 ViewDefinition built from `json`, for exercising
4516    /// `validate_view_definition` (#821) the same way a real caller of this
4517    /// library — `sof-cli`, `pysof`, or `PreparedViewDefinition::new` itself
4518    /// — would end up with one.
4519    #[cfg(feature = "R4")]
4520    fn view_definition_from_json(json: serde_json::Value) -> helios_fhir::r4::ViewDefinition {
4521        serde_json::from_value(json).expect("test fixture must deserialize as a ViewDefinition")
4522    }
4523
4524    /// Unwraps `err` as `SofError::InvalidViewDefinition`'s message, or
4525    /// fails the test with what it actually got.
4526    #[cfg(feature = "R4")]
4527    fn invalid_view_definition_message(err: SofError) -> String {
4528        match err {
4529            SofError::InvalidViewDefinition(message) => message,
4530            other => panic!("expected SofError::InvalidViewDefinition, got {other:?}"),
4531        }
4532    }
4533
4534    /// Unwraps `err` as `SofError::FhirPathError`'s message, or fails the
4535    /// test with what it actually got.
4536    #[cfg(feature = "R4")]
4537    fn fhirpath_error_message(err: SofError) -> String {
4538        match err {
4539            SofError::FhirPathError(message) => message,
4540            other => panic!("expected SofError::FhirPathError, got {other:?}"),
4541        }
4542    }
4543
4544    #[cfg(feature = "R4")]
4545    #[test]
4546    fn validate_view_definition_reports_missing_resource() {
4547        let vd = view_definition_from_json(serde_json::json!({
4548            "status": "active",
4549            "select": [{ "column": [{ "name": "id", "path": "id" }] }]
4550        }));
4551        let message = invalid_view_definition_message(
4552            validate_view_definition(&vd).expect_err("missing `resource` must fail"),
4553        );
4554        assert!(
4555            message.contains("missing required key `resource`"),
4556            "got: {message}"
4557        );
4558        // The document root's own pointer is empty, which the library-facing
4559        // message renders as `/` rather than a blank suffix.
4560        assert!(message.ends_with("at /"), "got: {message}");
4561    }
4562
4563    #[cfg(feature = "R4")]
4564    #[test]
4565    fn validate_view_definition_reports_unknown_resource_type() {
4566        let vd = view_definition_from_json(serde_json::json!({
4567            "status": "active",
4568            "resource": "Nope",
4569            "select": [{ "column": [{ "name": "id", "path": "id" }] }]
4570        }));
4571        let message = invalid_view_definition_message(
4572            validate_view_definition(&vd).expect_err("unknown `resource` type must fail"),
4573        );
4574        assert!(
4575            message.contains("unknown resource type \"Nope\""),
4576            "got: {message}"
4577        );
4578    }
4579
4580    #[cfg(feature = "R4")]
4581    #[test]
4582    fn validate_view_definition_reports_missing_select() {
4583        let vd = view_definition_from_json(serde_json::json!({
4584            "status": "active",
4585            "resource": "Patient"
4586        }));
4587        let message = invalid_view_definition_message(
4588            validate_view_definition(&vd).expect_err("missing `select` must fail"),
4589        );
4590        assert!(
4591            message.contains("missing required key `select`"),
4592            "got: {message}"
4593        );
4594    }
4595
4596    #[cfg(feature = "R4")]
4597    #[test]
4598    fn validate_view_definition_reports_empty_select() {
4599        let vd = view_definition_from_json(serde_json::json!({
4600            "status": "active",
4601            "resource": "Patient",
4602            "select": []
4603        }));
4604        let message = invalid_view_definition_message(
4605            validate_view_definition(&vd).expect_err("empty `select` must fail"),
4606        );
4607        assert!(
4608            message.contains("required value must not be empty"),
4609            "got: {message}"
4610        );
4611    }
4612
4613    #[cfg(feature = "R4")]
4614    #[test]
4615    fn validate_view_definition_reports_multiple_iteration_directives() {
4616        let vd = view_definition_from_json(serde_json::json!({
4617            "status": "active",
4618            "resource": "Patient",
4619            "select": [{
4620                "forEach": "name",
4621                "repeat": ["telecom"],
4622                "column": [{ "name": "id", "path": "id" }]
4623            }]
4624        }));
4625        let message = invalid_view_definition_message(
4626            validate_view_definition(&vd).expect_err("forEach + repeat together must fail"),
4627        );
4628        assert!(message.contains("at most one of"), "got: {message}");
4629    }
4630
4631    /// A document whose only error-severity lint diagnostic is a FHIRPath
4632    /// syntax problem reports `SofError::FhirPathError`, not
4633    /// `InvalidViewDefinition` (#821) — `sof-cli` and `pysof` callers match
4634    /// on the exception/error *type* to tell a syntax problem apart from a
4635    /// structural one, exactly as they could before this function started
4636    /// catching syntax errors itself.
4637    #[cfg(feature = "R4")]
4638    #[test]
4639    fn validate_view_definition_reports_fhirpath_syntax_error_as_fhirpath_error() {
4640        let vd = view_definition_from_json(serde_json::json!({
4641            "status": "active",
4642            "resource": "Patient",
4643            "select": [{
4644                "column": [{ "name": "id", "path": "invalid.fhirpath.expression[invalid syntax" }]
4645            }]
4646        }));
4647        let message = fhirpath_error_message(
4648            validate_view_definition(&vd).expect_err("invalid FHIRPath syntax must fail"),
4649        );
4650        assert!(message.contains("FHIRPath"), "got: {message}");
4651        assert!(
4652            message.contains("at /select/0/column/0/path"),
4653            "got: {message}"
4654        );
4655    }
4656
4657    /// A document mixing a FHIRPath syntax error with another structural
4658    /// problem reports `SofError::InvalidViewDefinition` — the more general
4659    /// "something is structurally broken" variant wins whenever more than
4660    /// one kind of error is present (#821).
4661    #[cfg(feature = "R4")]
4662    #[test]
4663    fn validate_view_definition_reports_invalid_view_definition_when_syntax_error_is_not_the_only_problem()
4664     {
4665        // Two independent problems: `forEach` + `repeat` together
4666        // (MultipleIterationDirectives) and a `path` that doesn't parse
4667        // (FhirPathSyntax) — both survive the typed round-trip
4668        // `view_definition_from_json` does (unlike an unknown JSON key,
4669        // which the generated struct's `Deserialize` silently drops before
4670        // `validate_view_definition` ever sees it).
4671        let vd = view_definition_from_json(serde_json::json!({
4672            "status": "active",
4673            "resource": "Patient",
4674            "select": [{
4675                "forEach": "name",
4676                "repeat": ["telecom"],
4677                "column": [{ "name": "id", "path": "invalid.fhirpath.expression[invalid syntax" }]
4678            }]
4679        }));
4680        let message = invalid_view_definition_message(
4681            validate_view_definition(&vd).expect_err("mixed errors must fail"),
4682        );
4683        assert!(message.contains("at most one of"), "got: {message}");
4684    }
4685
4686    /// The one check `validate_view_definition` still runs itself (#821):
4687    /// `collection: false` depends on whether an ancestor `forEach` /
4688    /// `forEachOrNull` / `repeat` is in scope, which is a relationship
4689    /// between selects the structural lint deliberately doesn't model.
4690    /// Its message is unchanged by the delegation to the lint above.
4691    #[cfg(feature = "R4")]
4692    #[test]
4693    fn validate_view_definition_reports_collection_false_outside_foreach() {
4694        let vd = view_definition_from_json(serde_json::json!({
4695            "status": "active",
4696            "resource": "Patient",
4697            "select": [{
4698                "column": [{ "name": "id", "path": "id", "collection": false }]
4699            }]
4700        }));
4701        let message = invalid_view_definition_message(
4702            validate_view_definition(&vd).expect_err("collection: false outside forEach must fail"),
4703        );
4704        assert_eq!(
4705            message,
4706            "Column 'collection' attribute must be true when specified"
4707        );
4708    }
4709
4710    #[cfg(feature = "R4")]
4711    #[test]
4712    fn validate_view_definition_accepts_a_valid_document() {
4713        let vd = view_definition_from_json(serde_json::json!({
4714            "status": "active",
4715            "resource": "Patient",
4716            "select": [{ "column": [{ "name": "id", "path": "id" }] }]
4717        }));
4718        assert!(validate_view_definition(&vd).is_ok());
4719    }
4720
4721    /// #821: the generated FHIR struct's own `Serialize` impl carries no
4722    /// `resourceType` field (it's implied by which concrete type/enum
4723    /// variant the caller is on, not part of the struct's own JSON shape).
4724    /// `validate_view_definition` must add it back before linting, or every
4725    /// otherwise-valid document would be rejected as `not-a-view-definition`.
4726    #[cfg(feature = "R4")]
4727    #[test]
4728    fn validate_view_definition_lints_a_view_definition_missing_resource_type_in_its_own_serialization()
4729     {
4730        let vd = view_definition_from_json(serde_json::json!({
4731            "status": "active",
4732            "resource": "Patient",
4733            "select": [{ "column": [{ "name": "id", "path": "id" }] }]
4734        }));
4735
4736        let bare_json = serde_json::to_value(&vd).expect("struct must serialize");
4737        assert!(
4738            bare_json.get("resourceType").is_none(),
4739            "test assumption: the struct's own Serialize impl carries no \
4740             resourceType, got {bare_json}"
4741        );
4742
4743        assert!(
4744            validate_view_definition(&vd).is_ok(),
4745            "validate_view_definition must add resourceType back before linting a valid document"
4746        );
4747    }
4748}