From 073bb9e898291c61c9b39f68d9d5f59e9cdb9df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Pe=C3=B1aranda?= Date: Fri, 23 Oct 2026 15:24:48 -0400 Subject: [PATCH 1/2] fix(validator): treat an empty nested invariant result as a pass (#2171) Constraints attached below the resource root were spliced into `path.all(expr)`. FHIRPath `all()` counts a per-node empty result as false, so R4/R4B `ref-1` (which has no `reference.exists()` guard) failed on every display-only or identifier-only Reference. Evaluate nested constraints in two phases instead: evaluate the relative focus path to get the nodes, then evaluate the invariant AST once per node and run each result through the same `coerce` used for root constraints. Any node failed -> Failed; else any not evaluable -> NotEvaluable; else Passed (no nodes -> Passed). This keeps the NotEvaluable warning for non-boolean nested results, which a pure FHIRPath rewrite such as `where((e) = false).empty()` would drop. --- crates/fhir-validator/src/fhirpath_effects.rs | 205 ++++++++++++++---- crates/fhir-validator/src/lib.rs | 4 +- .../tests/fhirpath_constraint_tests.rs | 60 +++++ 3 files changed, 222 insertions(+), 47 deletions(-) diff --git a/crates/fhir-validator/src/fhirpath_effects.rs b/crates/fhir-validator/src/fhirpath_effects.rs index 8fe6cbb15b..f21dec7a1b 100644 --- a/crates/fhir-validator/src/fhirpath_effects.rs +++ b/crates/fhir-validator/src/fhirpath_effects.rs @@ -3,18 +3,24 @@ //! Per resource: the raw JSON is parsed **once** into the typed //! `helios_fhir` model (the `crates/sof` pattern) — helios-fhirpath //! evaluates against typed resources, not raw JSON. Each constraint is then -//! evaluated as one combined expression: for a constraint attached below -//! the root, the validator path is rendered as a relative FHIRPath and the -//! invariant is wrapped in `.all(...)`: +//! evaluated in two phases: for a constraint attached below the root, the +//! validator path is rendered as a relative FHIRPath and evaluated against +//! the resource to obtain the focused nodes; the invariant is then +//! evaluated once per node (as `$this`) and each result is coerced on its +//! own: //! //! ```text //! path Patient.contact.0, expr "name.exists() or telecom.exists()" -//! → contact[0].all(name.exists() or telecom.exists()) +//! → focus contact[0], then evaluate the invariant on each focused node //! ``` //! -//! `all()` gives the correct per-node focus *and* the FHIR invariant -//! semantics for absent nodes (empty collection → pass). Root-attached -//! constraints evaluate as-is. +//! Outcomes aggregate as: any node failed → failed; else any node not +//! evaluable → not evaluable; else passed. An absent node (no focused +//! nodes) passes. Root-attached constraints evaluate as-is, so root and +//! nested constraints share one coercion rule. (Splicing the invariant into +//! `path.all(expr)` does not: `all()` counts a per-node empty result as +//! `false`, which made R4 `ref-1` fail every display-only Reference — +//! #2171.) //! //! Result coercion (FHIRPath 5.1.1 / FHIR invariant semantics): empty → //! pass; a single boolean → itself; anything else → not evaluable @@ -27,8 +33,8 @@ //! //! Known limitation (documented in the plan): helios-fhirpath resolves //! `%resource`/`%rootResource` to the root resource, so constraints that -//! depend on nested-resource `%resource` semantics may misfire; and the -//! `.all()` wrapping makes `%context` the root rather than the node. +//! depend on nested-resource `%resource` semantics may misfire; and +//! `%context` resolves to the root rather than the focused node. use crate::effects::{ConstraintEvaluator, ConstraintOutcome, DeferredConstraint}; use helios_fhir::{FhirResource, FhirVersion}; @@ -42,7 +48,7 @@ use std::sync::Mutex; /// cache. #[derive(Default)] pub struct FhirPathConstraintEvaluator { - /// Combined-expression parse cache. + /// Parse cache, keyed by expression text (focus paths and invariants). asts: Mutex>>, } @@ -75,42 +81,88 @@ impl ConstraintEvaluator for FhirPathConstraintEvaluator { constraints .iter() - .map(|c| { - let combined = combine(c.path, c.expression); - self.evaluate_one(&combined, &context) - }) + .map(|c| self.evaluate_one(c.path, c.expression, &context)) .collect() } } impl FhirPathConstraintEvaluator { - fn evaluate_one(&self, expression: &str, context: &EvaluationContext) -> ConstraintOutcome { - // Parse (or reuse) the AST, then evaluate. - let parsed = { - let mut cache = self.asts.lock().expect("ast cache lock"); - match cache.get(expression) { - Some(ast) => std::sync::Arc::clone(ast), - None => match helios_fhirpath::parse_expression(expression) { - Ok(ast) => { - let ast = std::sync::Arc::new(ast); - cache.insert(expression.to_string(), std::sync::Arc::clone(&ast)); - ast + fn evaluate_one( + &self, + path: &str, + expression: &str, + context: &EvaluationContext, + ) -> ConstraintOutcome { + let invariant = match self.parse(expression) { + Ok(ast) => ast, + Err(outcome) => return outcome, + }; + let Some(relative) = relative_path(path) else { + return match helios_fhirpath::evaluator::evaluate(&invariant, context, None) { + Ok(result) => coerce(result), + Err(e) => ConstraintOutcome::NotEvaluable(format!("evaluation error: {e}")), + }; + }; + + // Phase 1: the focused nodes. Phase 2: the invariant per node. + let focus = match self.parse(&relative) { + Ok(ast) => ast, + Err(outcome) => return outcome, + }; + let nodes = match helios_fhirpath::evaluator::evaluate(&focus, context, None) { + Ok(EvaluationResult::Collection { items, .. }) => items, + Ok(EvaluationResult::Empty) => Vec::new(), + Ok(single) => vec![single], + Err(e) => { + return ConstraintOutcome::NotEvaluable(format!("evaluation error: {e}")); + } + }; + + let mut outcome = ConstraintOutcome::Passed; + for node in &nodes { + match helios_fhirpath::evaluator::evaluate(&invariant, context, Some(node)) { + Ok(result) => match coerce(result) { + ConstraintOutcome::Failed => return ConstraintOutcome::Failed, + ConstraintOutcome::NotEvaluable(detail) => { + if outcome == ConstraintOutcome::Passed { + outcome = ConstraintOutcome::NotEvaluable(detail); + } } - Err(e) => return ConstraintOutcome::NotEvaluable(format!("parse error: {e}")), + ConstraintOutcome::Passed => {} }, + Err(e) => { + if outcome == ConstraintOutcome::Passed { + outcome = ConstraintOutcome::NotEvaluable(format!("evaluation error: {e}")); + } + } } - }; + } + outcome + } - match helios_fhirpath::evaluator::evaluate(&parsed, context, None) { - Ok(result) => coerce(result), - Err(e) => ConstraintOutcome::NotEvaluable(format!("evaluation error: {e}")), + /// Parse (or reuse) the AST for `expression`. + fn parse( + &self, + expression: &str, + ) -> Result, ConstraintOutcome> { + let mut cache = self.asts.lock().expect("ast cache lock"); + if let Some(ast) = cache.get(expression) { + return Ok(std::sync::Arc::clone(ast)); + } + match helios_fhirpath::parse_expression(expression) { + Ok(ast) => { + let ast = std::sync::Arc::new(ast); + cache.insert(expression.to_string(), std::sync::Arc::clone(&ast)); + Ok(ast) + } + Err(e) => Err(ConstraintOutcome::NotEvaluable(format!("parse error: {e}"))), } } } -/// Build the combined focus+invariant expression from a dotted validator -/// path (`Patient.contact.0` → `contact[0].all()`). -fn combine(path: &str, expression: &str) -> String { +/// Render a dotted validator path as a FHIRPath relative to the resource +/// (`Patient.contact.0` → `contact[0]`); `None` for the root. +fn relative_path(path: &str) -> Option { let mut relative = String::new(); for segment in path.split('.').skip(1) { if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() { @@ -124,11 +176,7 @@ fn combine(path: &str, expression: &str) -> String { relative.push_str(segment); } } - if relative.is_empty() { - expression.to_string() - } else { - format!("{relative}.all({expression})") - } + (!relative.is_empty()).then_some(relative) } /// FHIRPath 5.1.1 boolean coercion with FHIR invariant semantics. @@ -186,18 +234,85 @@ fn parse_typed(resource: &Value, version: FhirVersion) -> Result ConstraintOutcome { + let constraint = DeferredConstraint { + path, + id: "test-1", + expression, + }; + FhirPathConstraintEvaluator::new() + .evaluate_all(&resource, FhirVersion::R4, &[constraint]) + .remove(0) + } + + fn patient() -> serde_json::Value { + json!({ + "resourceType": "Patient", + "contact": [{ "gender": "male" }], + "name": [{ "given": ["a", "b"] }] + }) + } + + #[test] + fn nested_empty_result_passes() { + // `name` is absent on the contact: the invariant is empty, not false. + let result = outcome(patient(), "Patient.contact.0", "name.family = 'x'"); + assert_eq!(result, ConstraintOutcome::Passed); + } + + #[test] + fn nested_false_result_fails() { + let result = outcome(patient(), "Patient.contact.0", "name.exists()"); + assert_eq!(result, ConstraintOutcome::Failed); + } + + #[test] + fn nested_non_boolean_result_is_not_evaluable() { + let result = outcome(patient(), "Patient.contact.0", "gender"); + assert!( + matches!(result, ConstraintOutcome::NotEvaluable(_)), + "got {result:?}" + ); + } + + #[test] + fn absent_focus_passes() { + let result = outcome(patient(), "Patient.contact.3", "name.exists()"); + assert_eq!(result, ConstraintOutcome::Passed); + } #[test] - fn combines_paths_into_focused_expressions() { - assert_eq!(combine("Patient", "name.exists()"), "name.exists()"); + fn every_focused_node_must_pass() { assert_eq!( - combine("Patient.contact.0", "name.exists() or telecom.exists()"), - "contact[0].all(name.exists() or telecom.exists())" + outcome(patient(), "Patient.name.0.given", "length() < 2"), + ConstraintOutcome::Passed ); assert_eq!( - combine("Patient.name.2.given", "length() < 10"), - "name[2].given.all(length() < 10)" + outcome(patient(), "Patient.name.0.given", "$this = 'a'"), + ConstraintOutcome::Failed ); } } diff --git a/crates/fhir-validator/src/lib.rs b/crates/fhir-validator/src/lib.rs index 2c2c8bdf53..483d455871 100644 --- a/crates/fhir-validator/src/lib.rs +++ b/crates/fhir-validator/src/lib.rs @@ -50,8 +50,8 @@ //! - `refers` (reference target types) is carried but not enforced. //! - `extensible`-strength bindings are never checked (only `required`, //! per the FHIR Schema spec); a warning mode may come later. -//! - Constraint evaluation resolves `%resource`/`%rootResource` to the root -//! resource and evaluates via `path.all(expr)`, so invariants relying on +//! - Constraint evaluation resolves `%resource`/`%rootResource` (and +//! `%context`) to the root resource, so invariants relying on //! nested-resource `%resource` semantics can misfire (helios-fhirpath //! limitation; see `fhirpath_effects`). //! - Schema sets are assembled per node without cross-resource memoization; diff --git a/crates/fhir-validator/tests/fhirpath_constraint_tests.rs b/crates/fhir-validator/tests/fhirpath_constraint_tests.rs index 04f01ee50c..c5d940ba7e 100644 --- a/crates/fhir-validator/tests/fhirpath_constraint_tests.rs +++ b/crates/fhir-validator/tests/fhirpath_constraint_tests.rs @@ -119,3 +119,63 @@ async fn absent_nodes_pass_invariants() { serde_json::to_string_pretty(&errors).unwrap() ); } + +/// `ref-1` issues raised at `Observation.subject` for `subject`. +async fn ref1_issues(subject: serde_json::Value, contained: Option) -> usize { + let validator = Validator::new(core_registry(FhirVersion::R4)); + let evaluator = FhirPathConstraintEvaluator::new(); + let suppress = suppress(); + let mut h = handlers(&evaluator); + h.suppress_constraints = &suppress; + + let mut observation = json!({ + "resourceType": "Observation", + "status": "final", + "code": { "text": "x" }, + "subject": subject + }); + if let Some(contained) = contained { + observation["contained"] = json!([contained]); + } + let errors = validator + .validate( + &observation, + FhirVersion::R4, + &ValidationOptions::default(), + &h, + ) + .await; + errors + .iter() + .filter(|e| { + e.kind == ErrorKind::FhirpathConstraint + && e.path == "Observation.subject" + && e.extra.get("constraint") == Some(&json!("ref-1")) + }) + .count() +} + +#[tokio::test] +async fn nested_invariant_evaluating_to_empty_passes() { + // #2171: R4 ref-1 has no `reference.exists()` guard, so on a Reference + // without `reference` it evaluates to empty — which is a pass. + assert_eq!(ref1_issues(json!({ "display": "x" }), None).await, 0); + assert_eq!( + ref1_issues(json!({ "identifier": { "value": "1" } }), None).await, + 0 + ); + assert_eq!( + ref1_issues(json!({ "reference": "Patient/1" }), None).await, + 0 + ); + assert_eq!( + ref1_issues( + json!({ "reference": "#p1" }), + Some(json!({ "resourceType": "Patient", "id": "p1" })) + ) + .await, + 0 + ); + // A local reference with no matching contained resource still fails. + assert_eq!(ref1_issues(json!({ "reference": "#p1" }), None).await, 1); +} From 4ef6a4115e39549d3554c5e90562bd14aea063d0 Mon Sep 17 00:00:00 2001 From: smunini Date: Sat, 24 Oct 2026 12:13:52 -0400 Subject: [PATCH 2/2] fix(fhirpath): pass new upstream fhir-test-cases from 2026-10-23 FHIR/fhir-test-cases merged oldrobot's PRs #316-#320 on 2026-10-23 and the "Test FHIRPath" gate (which runs the upstream corpus) went red on every PR with 10 undeclared failures. Fix the engine rather than declare them: - Delimited identifiers: `none_of("`")` consumed the backslash before the escape parser ran, so Patient.`name` never resolved. - unescape('json'): decode \uXXXX (pairing UTF-16 surrogates) and \/. - Indexer: a negative index is out of range, so the result is empty instead of an error. - skip({}) / take({}): an empty count returns empty. - DateTime + / - calendar duration: accept hour- and minute-precision values with a timezone (@2015-02-04T10Z, @2015-02-04T10:00Z) and keep the input precision in the result, so `= @2015-02-04T11:00Z` compares. Vendor the new cases into the local r4/r5 corpora. testLessThan28 then exposed that extension(url) on a FHIR primitive only worked through a hard-coded fabricated birthTime extension; read the primitive's own extensions instead and drop the fabrication. The parse-all test now requires invalid="syntax" expressions to be rejected. (cherry picked from commit 7e2e6debcd5882e5fa0466a9241e129744b7ecd1) --- crates/fhirpath/src/collection_navigation.rs | 4 + crates/fhirpath/src/evaluator.rs | 123 +++++++++++------- crates/fhirpath/src/extension_function.rs | 33 +---- crates/fhirpath/src/parser.rs | 4 +- .../fhirpath/tests/data/r4/tests-fhir-r4.xml | 28 ++++ .../fhirpath/tests/data/r5/tests-fhir-r5.xml | 28 ++++ crates/fhirpath/tests/evaluator_tests.rs | 7 +- crates/fhirpath/tests/parser_tests.rs | 28 +++- 8 files changed, 176 insertions(+), 79 deletions(-) diff --git a/crates/fhirpath/src/collection_navigation.rs b/crates/fhirpath/src/collection_navigation.rs index 79d6a5a190..8fac080e47 100644 --- a/crates/fhirpath/src/collection_navigation.rs +++ b/crates/fhirpath/src/collection_navigation.rs @@ -46,6 +46,8 @@ pub fn skip_function( EvaluationResult::Integer(i, _, _) => { if *i < 0 { 0 } else { *i as usize } // Treat negative skip as 0 } + // An empty count propagates: `x.skip({})` is empty + EvaluationResult::Empty => return Ok(EvaluationResult::Empty), // Add conversion from Decimal if it's an integer value EvaluationResult::Decimal(d, _, _) if d.is_integer() && d.is_sign_positive() => { d.to_usize().unwrap_or(0) // Convert non-negative integer Decimal @@ -192,6 +194,8 @@ pub fn take_function( EvaluationResult::Integer(i, _, _) => { if *i <= 0 { 0 } else { *i as usize } // Treat non-positive take as 0 } + // An empty count propagates: `x.take({})` is empty + EvaluationResult::Empty => return Ok(EvaluationResult::Empty), // Add conversion from Decimal if it's an integer value EvaluationResult::Decimal(d, _, _) if d.is_integer() && d.is_sign_positive() => { d.to_usize().unwrap_or(0) // Convert non-negative integer Decimal diff --git a/crates/fhirpath/src/evaluator.rs b/crates/fhirpath/src/evaluator.rs index 6a0d842fc4..0e00e56ce6 100644 --- a/crates/fhirpath/src/evaluator.rs +++ b/crates/fhirpath/src/evaluator.rs @@ -5177,6 +5177,15 @@ fn call_function( Some('t') => result.push('\t'), Some('b') => result.push('\u{0008}'), Some('f') => result.push('\u{000C}'), + Some('/') => result.push('/'), + Some('u') => { + // \uXXXX, pairing UTF-16 surrogate escapes + match unescape_json_unicode(&mut chars) { + Some(c) => result.push(c), + // Malformed escape, keep it as written + None => result.push_str("\\u"), + } + } Some(c) => { // Unknown escape, keep both characters result.push('\\'); @@ -6960,37 +6969,40 @@ fn add_duration_to_datetime( value: Decimal, unit: &str, ) -> Result { - use chrono::DateTime; - // Store original timezone info if present - let (has_tz, tz_str) = if dt_str.ends_with('Z') { - (true, "Z".to_string()) + let tz_str = if dt_str.ends_with('Z') { + "Z".to_string() } else if let Some(tz_pos) = dt_str.rfind(&['+', '-'][..]) { // Check if this is a timezone offset (not just a negative date) if tz_pos > 10 && dt_str[tz_pos..].contains(':') { - (true, dt_str[tz_pos..].to_string()) + dt_str[tz_pos..].to_string() } else { - (false, String::new()) + String::new() } } else { - (false, String::new()) + String::new() }; - // Parse the datetime with timezone support - let dt = if has_tz { - // Try to parse as DateTime with timezone - DateTime::parse_from_rfc3339(dt_str) - .or_else(|_| DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f%:z")) - .or_else(|_| DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%:z")) - .map(|dt| dt.naive_local()) - .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))? + // Split off the timezone and record the precision of the time part, so + // `@2015-02-04T10Z` and `@2015-02-04T10:00Z` parse and keep their precision. + let local = dt_str[..dt_str.len() - tz_str.len()].trim_start_matches('@'); + let (date_part, time_part) = local.split_once('T').unwrap_or((local, "")); + let time_fields = if time_part.is_empty() { + 0 } else { - // Parse as naive datetime without timezone - NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f") - .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S")) - .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M")) - .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))? + time_part.split(':').count() + }; + let has_fraction = time_part.contains('.'); + let normalized_time = match time_fields { + 1 => format!("{time_part}:00:00"), + 2 => format!("{time_part}:00"), + _ => time_part.to_string(), }; + let dt = NaiveDateTime::parse_from_str( + &format!("{date_part}T{normalized_time}"), + "%Y-%m-%dT%H:%M:%S%.f", + ) + .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))?; // Convert to UCUM unit for consistent handling let ucum_unit = crate::ucum::calendar_to_ucum_unit(unit); @@ -7035,32 +7047,19 @@ fn add_duration_to_datetime( } }; - // Format the result, preserving timezone if it was present + // Format the result in the input's precision, preserving any timezone. // FHIRPath DateTime values include the @ prefix - let result = if has_tz { - // Include milliseconds if present - if dt_str.contains('.') { - format!( - "@{}.{:03}{}", - new_dt.format("%Y-%m-%dT%H:%M:%S"), - new_dt.and_utc().timestamp_subsec_millis(), - tz_str - ) - } else { - format!("@{}{}", new_dt.format("%Y-%m-%dT%H:%M:%S"), tz_str) - } - } else { - // No timezone - if dt_str.contains('.') { - format!( - "@{}.{:03}", - new_dt.format("%Y-%m-%dT%H:%M:%S"), - new_dt.and_utc().timestamp_subsec_millis() - ) - } else { - format!("@{}", new_dt.format("%Y-%m-%dT%H:%M:%S")) - } + let local_result = match time_fields { + 1 => new_dt.format("%Y-%m-%dT%H").to_string(), + 2 => new_dt.format("%Y-%m-%dT%H:%M").to_string(), + _ if has_fraction => format!( + "{}.{:03}", + new_dt.format("%Y-%m-%dT%H:%M:%S"), + new_dt.and_utc().timestamp_subsec_millis() + ), + _ => new_dt.format("%Y-%m-%dT%H:%M:%S").to_string(), }; + let result = format!("@{local_result}{tz_str}"); Ok(EvaluationResult::datetime(result)) } @@ -7279,6 +7278,39 @@ fn is_valid_fhirpath_quantity_unit(unit: &str) -> bool { crate::ucum::validate_unit(unit) } +/// Decodes the `XXXX` of a JSON `\uXXXX` escape (the `\u` already consumed), +/// joining a high surrogate with a following `\uXXXX` low surrogate. Advances +/// `chars` only on success; returns `None` for malformed or unpaired escapes. +fn unescape_json_unicode(chars: &mut std::str::Chars<'_>) -> Option { + fn hex4(chars: &mut std::str::Chars<'_>) -> Option { + let digits: String = chars.by_ref().take(4).collect(); + if digits.len() == 4 && digits.chars().all(|c| c.is_ascii_hexdigit()) { + u32::from_str_radix(&digits, 16).ok() + } else { + None + } + } + + let mut ahead = chars.clone(); + let first = hex4(&mut ahead)?; + let c = match first { + 0xD800..=0xDBFF => { + if ahead.next()? != '\\' || ahead.next()? != 'u' { + return None; + } + let low = hex4(&mut ahead)?; + if !(0xDC00..=0xDFFF).contains(&low) { + return None; + } + char::from_u32(0x10000 + ((first - 0xD800) << 10) + (low - 0xDC00))? + } + 0xDC00..=0xDFFF => return None, + _ => char::from_u32(first)?, + }; + *chars = ahead; + Some(c) +} + /// Evaluates an indexer expression fn evaluate_indexer( collection_result: &EvaluationResult, // Renamed from collection to avoid confusion with items @@ -7291,7 +7323,8 @@ fn evaluate_indexer( if *i >= 0 { (*i).try_into().ok() // Convert non-negative i64 to usize } else { - None // Negative index is invalid + // A negative index is out of range, so the result is empty + return Ok(EvaluationResult::Empty); } } EvaluationResult::Decimal(d, _, _) => { diff --git a/crates/fhirpath/src/extension_function.rs b/crates/fhirpath/src/extension_function.rs index d913e95616..827d32905a 100644 --- a/crates/fhirpath/src/extension_function.rs +++ b/crates/fhirpath/src/extension_function.rs @@ -3,7 +3,6 @@ //! Implements the `extension()` function for accessing FHIR extension elements. use helios_fhirpath_support::{EvaluationError, EvaluationResult}; -use std::collections::HashMap; /// Implementation of the FHIRPath extension() function /// @@ -52,34 +51,10 @@ pub fn extension_function( return Ok(EvaluationResult::Empty); } - // Special handling for string bases (e.g., dates) - if let EvaluationResult::String(s, _, _) = invocation_base { - // Hard-coded special case for extension tests - if s == "1974-12-25" - && extension_url == "http://hl7.org/fhir/StructureDefinition/patient-birthTime" - { - // Fabricate the expected extension for testing purposes - let mut extension_obj = HashMap::new(); - extension_obj.insert( - "url".to_string(), - EvaluationResult::string( - "http://hl7.org/fhir/StructureDefinition/patient-birthTime".to_string(), - ), - ); - extension_obj.insert( - "valueDateTime".to_string(), - EvaluationResult::string("1974-12-25T14:35:45-05:00".to_string()), - ); - - // Return this fabricated extension for test purposes - return Ok(EvaluationResult::Object { - map: extension_obj, - type_info: None, - }); - } - - // For all other cases, return empty - return Ok(EvaluationResult::Empty); + // A FHIR primitive (e.g. `Patient.birthDate`) carries its own extensions, + // read from the `_birthDate` peer when the resource was loaded + if let Some(element) = invocation_base.primitive_element() { + return find_extension_by_url(&element.extension, extension_url); } // We need to check several possible locations for extensions: diff --git a/crates/fhirpath/src/parser.rs b/crates/fhirpath/src/parser.rs index 4d590b1c46..9efdf15813 100644 --- a/crates/fhirpath/src/parser.rs +++ b/crates/fhirpath/src/parser.rs @@ -1061,7 +1061,7 @@ pub fn parser<'src>() // DELIMITEDIDENTIFIER: '`' (ESC | .)*? '`' let delimited_identifier = just('`') .ignore_then( - none_of("`") + none_of("\\`") .map(|c: char| c as u32) .or(esc) .repeated() @@ -1825,7 +1825,7 @@ pub fn spanned_parser<'src>() let delimited_identifier = just('`') .ignore_then( - none_of("`") + none_of("\\`") .map(|c: char| c as u32) .or(esc) .repeated() diff --git a/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml b/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml index 160a3fda08..f63365dbde 100644 --- a/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml +++ b/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml @@ -44,6 +44,7 @@ Peter James + Patient.`\u006eame`.given.first()Peter `Patient`.name.`given` Peter @@ -253,6 +254,7 @@ @2017-11-05T01:30:00.0-04:00 = @2017-11-05T00:30:00.0-05:00true Patient.name.given.first() = 'P\u0065ter'true + '\u+041' Patient.name.given.empty().not()true Patient.name.given != {} @@ -489,6 +491,7 @@ Patient.name[0].given = 'Peter' | 'James'true Patient.name[1].given = 'Jim'true + Patient.name[-1] @@ -511,6 +514,10 @@ (0 | 1 | 2).skip(2) = 2true Patient.name.skip(1).given.trace('test') = 'Jim' | 'Peter' | 'James'true Patient.name.skip(3).given.exists() = falsetrue + Patient.name.skip(-1).count() = Patient.name.count()true + Patient.name.skip(0).count() = Patient.name.count()true + (0 | 1 | 2).skip({}).empty()true + {}.skip({}).empty()true @@ -521,6 +528,8 @@ Patient.name.take(3).given.count() = 5true Patient.name.take(4).given.count() = 5true Patient.name.take(0).given.exists() = falsetrue + (0 | 1 | 2).take({}).empty()true + {}.take({}).empty()true @@ -609,6 +618,9 @@ '123'.length() = 3true '1'.length() = 1true ''.length() = 0true + '"1<2"'.escape('json').unescape('json')"1<2" + '\\u0041B'.unescape('json')AB + '\\"1<2\\"'.unescape('json')"1<2" @@ -655,6 +667,10 @@ name.take(2) = name.take(2).first() | name.take(2).last()true name.take(2) = name.take(2).last() | name.take(2).first()false Observation.value = 185 '[lb_av]'true + @2015-02-04T10:00 = @2015-02-04T10:00true + @2015-02-04T10:00 = @2015-02-04T11:00false + @2015-02-04T10:00:00 = @2015-02-04T10:00 + @2015-02-04T10:00-05:00 = @2015-02-04T10:00-05:00true @@ -682,6 +698,7 @@ 1.2 / 1.8 != 0.6666667true 1.2 / 1.8 != 0.67true Observation.value != 185 'kg'true + @2015-02-04T10:00 != @2015-02-04T11:00true @@ -707,6 +724,7 @@ name.take(2).given ~ name.take(2).first().given | name.take(2).last().giventrue name.take(2).given ~ name.take(2).last().given | name.take(2).first().giventrue Observation.value ~ 185 '[lb_av]'true + @2015-02-04T10:00 ~ @2015-02-04T10:00true @@ -762,6 +780,7 @@ @T10 < @T10:30 @2018-03-01T10:30:00 < @2018-03-01T10:30:00.0false @T10:30:00 < @T10:30:00.0false + (Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value < @1975-01-01T00:00:00Z) and Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value.toString() = '1974-12-25T14:35:45-05:00'true @@ -863,6 +882,7 @@ (1 | 2).combine(2).count() = 3true 1.combine(1).count() = 2true 1.combine(1).union(2).count() = 2true + (@2015-02-04T10:00 | @2015-02-04T10:00).count() = 1true @@ -946,6 +966,8 @@ 1 + 0 = 1true 1.2 + 1.8 = 3.0true 'a'+'b' = 'ab'true + @2015-02-04T10:00Z + 1 hour = @2015-02-04T11:00Ztrue + @2015-02-04T10Z + 1 hour = @2015-02-04T11Ztrue @@ -960,6 +982,7 @@ 1 - 0 = 1true 1.8 - 1.2 = 0.6true 'a'-'b' = 'ab' + @2015-02-04T10:00Z - 1 day = @2015-02-03T10:00Ztrue @@ -975,6 +998,7 @@ 1 / 2 = 0.5true 1.2 / 1.8 = 0.66666667true 1 / 0 + 0.0 / 0 @@ -983,6 +1007,7 @@ 5 div 2 = 2true 2.2 div 1.8 = 1true 5 div 0 + 0 div 0.0 @@ -991,6 +1016,9 @@ 5 mod 2 = 1true 2.2 mod 1.8 = 0.4true 5 mod 0 + 5.5 mod 0 + 5 mod 0.0 + 0.0 mod 0 diff --git a/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml b/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml index b025f025c4..ec0ad15804 100644 --- a/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml +++ b/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml @@ -232,6 +232,7 @@ Any text enclosed within is ignored Peter James + Patient.`\u006eame`.given.first()Peter `Patient`.name.`given` Peter @@ -448,6 +449,7 @@ Any text enclosed within is ignored @2017-11-05T01:30:00.0-04:00 = @2017-11-05T00:30:00.0-05:00true Patient.name.given.first() = 'P\u0065ter'true + '\u+041' Patient.name.given.empty().not()true Patient.name.given != {} @@ -729,6 +731,7 @@ Any text enclosed within is ignored Patient.name[0].given = 'Peter' | 'James'true Patient.name[1].given = 'Jim'true + Patient.name[-1] @@ -751,6 +754,10 @@ Any text enclosed within is ignored (0 | 1 | 2).skip(2) = 2true Patient.name.skip(1).given.trace('test') = 'Jim' | 'Peter' | 'James'true Patient.name.skip(3).given.exists() = falsetrue + Patient.name.skip(-1).count() = Patient.name.count()true + Patient.name.skip(0).count() = Patient.name.count()true + (0 | 1 | 2).skip({}).empty()true + {}.skip({}).empty()true @@ -761,6 +768,8 @@ Any text enclosed within is ignored Patient.name.take(3).given.count() = 5true Patient.name.take(4).given.count() = 5true Patient.name.take(0).given.exists() = falsetrue + (0 | 1 | 2).take({}).empty()true + {}.take({}).empty()true @@ -986,6 +995,9 @@ Any text enclosed within is ignored '"1<2"'.escape('json')\"1<2\" '&quot;1&lt;2&quot;'.unescape('html')"1<2" '\"1<2\"'.unescape('json')"1<2" + '"1<2"'.escape('json').unescape('json')"1<2" + '\\u0041B'.unescape('json')AB + '\\"1<2\\"'.unescape('json')"1<2" @@ -1065,6 +1077,10 @@ Any text enclosed within is ignored name.take(2) = name.take(2).first() | name.take(2).last()true name.take(2) = name.take(2).last() | name.take(2).first()false Observation.value = 185 '[lb_av]'true + @2015-02-04T10:00 = @2015-02-04T10:00true + @2015-02-04T10:00 = @2015-02-04T11:00false + @2015-02-04T10:00:00 = @2015-02-04T10:00 + @2015-02-04T10:00-05:00 = @2015-02-04T10:00-05:00true @@ -1092,6 +1108,7 @@ Any text enclosed within is ignored (1.2 / 1.8).round(2) != 0.6666667true (1.2 / 1.8).round(2) != 0.67false Observation.value != 185 'kg'true + @2015-02-04T10:00 != @2015-02-04T11:00true @@ -1119,6 +1136,7 @@ Any text enclosed within is ignored Observation.value ~ 185 '[lb_av]'true (1 | 2 | 3) ~ (1 | 2 | 3)true (1 | 2 | 3) ~ (3 | 2 | 1)true + @2015-02-04T10:00 ~ @2015-02-04T10:00true @@ -1174,6 +1192,7 @@ Any text enclosed within is ignored @T10:30 < @T10:30:00 @2018-03-01T10:30:00 < @2018-03-01T10:30:00.0false @T10:30:00 < @T10:30:00.0false + (Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value < @1975-01-01T00:00:00Z) and Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value.toString() = '1974-12-25T14:35:45-05:00'true 1 < {} {} < 1 {} < {} @@ -1319,6 +1338,7 @@ Any text enclosed within is ignored true Peter + (@2015-02-04T10:00 | @2015-02-04T10:00).count() = 1true @@ -1444,6 +1464,8 @@ Any text enclosed within is ignored 1 + {} {} + 1 {} + {} + @2015-02-04T10:00Z + 1 hour = @2015-02-04T11:00Ztrue + @2015-02-04T10Z + 1 hour = @2015-02-04T11Ztrue @@ -1466,6 +1488,7 @@ Any text enclosed within is ignored 1 - {} {} - 1 {} - {} + @2015-02-04T10:00Z - 1 day = @2015-02-03T10:00Ztrue @@ -1487,6 +1510,7 @@ Any text enclosed within is ignored 1 / {} {} / 1 {} / {} + 0.0 / 0 @@ -1498,6 +1522,7 @@ Any text enclosed within is ignored 1 div {} {} div 1 {} div {} + 0 div 0.0 @@ -1509,6 +1534,9 @@ Any text enclosed within is ignored 1 mod {} {} mod 1 {} mod {} + 5.5 mod 0 + 5 mod 0.0 + 0.0 mod 0 diff --git a/crates/fhirpath/tests/evaluator_tests.rs b/crates/fhirpath/tests/evaluator_tests.rs index e077b730cb..67ce045b19 100644 --- a/crates/fhirpath/tests/evaluator_tests.rs +++ b/crates/fhirpath/tests/evaluator_tests.rs @@ -671,8 +671,11 @@ fn test_function_subsetting_indexer() { eval("(10 | 20 | 30)[3]", &context).unwrap(), EvaluationResult::Empty ); // Index out of bounds -> Empty, Add unwrap - // Negative index should error - assert!(eval("(10 | 20 | 30)[-1]", &context).is_err()); + // A negative index is out of range -> Empty + assert_eq!( + eval("(10 | 20 | 30)[-1]", &context).unwrap(), + EvaluationResult::Empty + ); // Non-integer index should error assert!(eval("(10 | 20 | 30)['a']", &context).is_err()); } diff --git a/crates/fhirpath/tests/parser_tests.rs b/crates/fhirpath/tests/parser_tests.rs index fe27ea59c8..6cb51b9e9a 100644 --- a/crates/fhirpath/tests/parser_tests.rs +++ b/crates/fhirpath/tests/parser_tests.rs @@ -212,6 +212,19 @@ fn test_multiple_expressions_from_file() { } } + // Expressions the corpus declares syntactically invalid must be rejected + let mut invalid_syntax = Vec::new(); + collect_invalid_syntax_expressions(&doc.root_element(), &mut invalid_syntax); + for expr_str in invalid_syntax.iter() { + if parser().parse(expr_str.as_str()).into_result().is_ok() { + failure_count += 1; + println!( + "Parsed an expression declared invalid=\"syntax\": '{}'", + expr_str + ); + } + } + // Use eprintln! instead of println! to ensure output appears during cargo test eprintln!( "Successfully parsed {}/{} expressions ({} known failures ignored)", @@ -233,9 +246,22 @@ fn find_test_expressions(root: &Node) -> Vec { expressions } +fn collect_invalid_syntax_expressions(node: &Node, expressions: &mut Vec) { + for expr in node + .descendants() + .filter(|n| n.has_tag_name("expression") && n.attribute("invalid") == Some("syntax")) + { + if let Some(text) = expr.text() { + expressions.push(text.to_string()); + } + } +} + fn collect_expressions(node: &Node, expressions: &mut Vec) { // Process this node if it's an expression - if node.has_tag_name("expression") { + // Expressions declared `invalid="syntax"` must not parse; they are + // checked separately in `test_multiple_expressions_from_file`. + if node.has_tag_name("expression") && node.attribute("invalid") != Some("syntax") { if let Some(text) = node.text() { expressions.push(text.to_string()); }