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}