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