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