fhirpath 0.2.0.pre3 → 0.2.0.pre4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55816199de3a062086023e1a40abdf4bf5496eab085f56d09c40348f3ef12099
4
- data.tar.gz: e1a22be7cd54908b7824217559b82211feed90eda7884fe898341734875e05d8
3
+ metadata.gz: eacb9dc2934ca76d58acb3c6aab97acc1f2d602f436d28d1518a0ee26a25c14d
4
+ data.tar.gz: 7460ad33f47fd12408aaa905318285cec5e05f4db3503dcfd3ea4612c2d3ab53
5
5
  SHA512:
6
- metadata.gz: 40667a9301563b5e7b8112234fcaaa13e9838efb83f5350b3aa5cd0aeb7ff40417320c8d296152c42a401873c857c5492ef20b3950434a525b4e36f46888d280
7
- data.tar.gz: 166e58f9d11b4abe1c6ce4f646b367b1ff6da7f7273983d10442c40bc8757bc70b859fc02097ac657185e1d01df27cd6efd7d996684c24f9ac1a06116d542730
6
+ metadata.gz: 3e5de5efdbd7037022c0e36587dbebec79ab0406da23b76067b858d49f47b9edfecff975ef9e048fb19fd3455c1e2e704ad4491f77bffb47517abc46e3d5fc64
7
+ data.tar.gz: 5e7b7ec8152d3534464c70b739e025d63b06490f3c3fe8dcb16dc26017af0597d888243057de6600ac5a922eddda05035a7a5254773085a6d738ef339b65d494
data/.rubocop.yml CHANGED
@@ -53,6 +53,7 @@ Metrics/ClassLength:
53
53
  - "test/subsetting_functions_test.rb"
54
54
  - "lib/fhirpath/conformance/importer.rb"
55
55
  - "test/conformance_pipeline_test.rb"
56
+ - "lib/fhirpath/models_r4.rb"
56
57
 
57
58
  Metrics/CyclomaticComplexity:
58
59
  Exclude:
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to this project are documented here. The project is pre-1.0;
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0.pre4] - 2026-09-07
8
+ - Add the `aggregate()` function with `$this`, `$total`, and `$index` variables for general-purpose collection aggregation (issue #43). The function signature is `aggregate(expression, initial)` where the expression is evaluated per item with the accumulator as `$total`. Empty collections return the initial value (or empty if omitted). Closes issue #43.
9
+ - Add keyword-named functions (`contains`, `in`) usable after member access (`Patient.name.contains('John')`) — the lexer previously classified these as binary operators only, preventing function-call syntax after `.` (issue #66). Also registers both as standard functions with arity 1.
10
+ - Accept raw JSON strings in `FHIRPath.evaluate()` and `CompiledExpression#evaluate`: strings starting with `{` or `[` after optional whitespace are parsed via `JSON.parse` once per call; malformed JSON raises `JSONInputError` (code `:invalid_json`); `Hash`/`Array` inputs pass through unchanged; other strings remain singleton string values (issue #56).
11
+ - Add bounded Quantity/UCUM support: immutable `BigDecimal`-backed `FHIRPath::Quantity` type with case-sensitive conversion for the explicit subset (`m`, `cm`, `mm`, `km`, `g`, `kg`, `mg`, `Mg`, `ug`, `ng`, `L`, `mL`, `ML`, `uL`, `mol`, `mmol`, `umol`, `s`, `min`, `h` and products/quotients such as `mmol/L`). Same-dimension comparison/addition/subtraction/multiplication by scalar/division; incompatible or unsupported-unit operations return empty. The UCUM parser is dependency-free and exponents are bounded to 12 to prevent CPU/memory amplification (issue #44).
12
+ - Expand R4 model adapter choice elements: `Medication`, `Condition`, `Procedure`, and `DiagnosticReport` choice elements now resolve through the FHIRPath navigation layer (issue #46).
13
+ - Import and check in the full official HL7 FHIRPath shared test suite (935 cases from `fhir-test-cases` release `1.7.69`) with a new `load_full_suite` importer mode that bypasses the pinned `case_ids` subset (issue #47).
14
+ - Document JSON input behavior in `docs/api.md` and add FHIR-native JSON examples to the README Quick Start.
15
+
16
+ - Add a dependency-free, explicitly bounded FHIRPath `Quantity` type. Quantity
17
+ values always store a finite `BigDecimal` and expose `value`, `unit`,
18
+ `system`, and `code`; integer quantity literals are promoted to Decimal.
19
+ Single- and double-quoted unit literals are supported, with case-sensitive
20
+ conversion for the tested subset of length, mass, volume, amount, and time
21
+ units plus simple products/quotients such as `mmol/L`. Same-dimension
22
+ quantities support comparison, addition, subtraction, scalar multiplication
23
+ and division, and Quantity/Quantity ratios. Unsupported units are rejected;
24
+ incompatible calculations return an empty collection. Quantity×Quantity and
25
+ derived-unit composition such as `km/h` remain deferred. This is not complete
26
+ UCUM conformance, and calendar-duration arithmetic remains deferred. The
27
+ parser also accepts double-quoted strings as a documented extension, while
28
+ normative FHIRPath string literals remain single-quoted. Wrong-case symbols
29
+ such as `MG` and `G` are rejected; exponent notation uses `^` (for example,
30
+ `m^2`). Mixed Quantity/scalar addition is empty in either operand order,
31
+ and derived-unit division such as `km/h` is deferred. Quantity `system` and
32
+ `code` metadata is preserved but does not change unit equality.
33
+
34
+
7
35
  ## [0.2.0.pre3] - 2026-09-06
8
36
  - Same library content as `0.2.0.pre2`. Version bumped because the earlier
9
37
  `0.2.0.pre2` tag was pushed to RubyGems by a partial release run before the
data/README.md CHANGED
@@ -4,6 +4,9 @@
4
4
  [![Release](https://github.com/niccoreyes/fhirpath-ruby/actions/workflows/release.yml/badge.svg)](https://github.com/niccoreyes/fhirpath-ruby/actions/workflows/release.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/niccoreyes/fhirpath-ruby/blob/main/LICENSE)
6
6
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3-blue.svg)](https://github.com/niccoreyes/fhirpath-ruby/blob/main/.github/workflows/ci.yml)
7
+ [![Gem Version](https://badge.fury.io/rb/fhirpath.svg)](https://rubygems.org/gems/fhirpath)
8
+ [![Gem Downloads](https://img.shields.io/gem/dt/fhirpath)](https://rubygems.org/gems/fhirpath)
9
+ [![RubyGems](https://img.shields.io/gem/v/fhirpath?logo=rubygems&logoColor=white)](https://rubygems.org/gems/fhirpath)
7
10
 
8
11
  A Ruby-native implementation of the [HL7 FHIRPath](https://hl7.org/fhirpath/) expression language.
9
12
 
@@ -11,10 +14,10 @@ This repository is an intentionally small, pre-release implementation. It provid
11
14
 
12
15
  ## Status at a glance
13
16
 
14
- - Version: `0.2.0.pre1`
17
+ - Version: `0.2.0.pre3`
15
18
  - Normative language target: FHIRPath `2.0.0`
16
19
  - Ruby support policy: Ruby `3.2` and `3.3` are tested in CI; newer Ruby versions are supported only after CI coverage is added.
17
- - Release status: pre-release; not published to RubyGems.
20
+ - Release status: pre-release; published to [RubyGems](https://rubygems.org/gems/fhirpath).
18
21
  - License: [MIT](LICENSE).
19
22
 
20
23
  The exact release-facing target, capability identifiers, Ruby support matrix,
@@ -22,19 +25,15 @@ and host/model limitations are maintained in the [release support matrix](docs/s
22
25
 
23
26
  ## Installation
24
27
 
25
- The gem is not yet published. To use a checkout:
28
+ The gem is published on [RubyGems.org](https://rubygems.org/gems/fhirpath):
26
29
 
27
30
  ```sh
28
- git clone https://github.com/niccoreyes/fhirpath-ruby.git
29
- cd fhirpath-ruby
30
- bundle install
31
- bundle exec rake test
31
+ gem install fhirpath
32
32
  ```
33
33
 
34
- Once a release is published, the intended consumer workflow will be:
34
+ Or in a Gemfile:
35
35
 
36
36
  ```ruby
37
- # Gemfile
38
37
  gem "fhirpath"
39
38
  ```
40
39
 
@@ -43,22 +42,38 @@ documented limitations before using this implementation in production.
43
42
 
44
43
  ## Quick start
45
44
 
45
+ ## Quick start
46
+
46
47
  ```ruby
47
48
  require "fhirpath"
48
49
 
49
- patient = {
50
- "resourceType" => "Patient",
51
- "name" => [
52
- { "use" => "official", "family" => "Lovelace", "given" => ["Ada", "Augusta"] },
53
- { "use" => "nickname", "given" => ["Addie"] }
54
- ]
55
- }
50
+ # Plain Ruby Hash/Array navigation
51
+ data = { items: [1, 2, 3, 4, 5] }
52
+ FHIRPath.evaluate(data, "items.where($this > 3)") # => [4, 5]
56
53
 
57
- FHIRPath.evaluate(patient, "Patient.name.where(use = 'official').given").to_a
58
- # => ["Ada", "Augusta"]
54
+ # FHIR R4 model adapter
55
+ observation = {
56
+ "resourceType" => "Observation",
57
+ "id" => "obs-1",
58
+ "status" => "final",
59
+ "code" => { "coding" => [{ "system" => "http://loinc.org", "code" => "8480-6" }] },
60
+ "valueQuantity" => { "value" => 5.5, "unit" => "mmol/L", "system" => "http://unitsofmeasure.org", "code" => "mmol/L" }
61
+ }
59
62
 
60
- FHIRPath.evaluate_first(patient, "Patient.name.family")
61
- # => "Lovelace"
63
+ # Navigate FHIR JSON with R4 model adapter
64
+ FHIRPath.evaluate(observation, "valueQuantity.value > 5.0", model: :r4) # => [true]
65
+ FHIRPath.evaluate(observation, "valueQuantity.unit", model: :r4) # => ["mmol/L"]
66
+ FHIRPath.evaluate(observation, "code.coding.system", model: :r4) # => ["http://loinc.org"]
67
+
68
+ # Type filtering with ofType()
69
+ bundle = {
70
+ "resourceType" => "Bundle",
71
+ "entry" => [
72
+ { "resource" => { "resourceType" => "Observation", "id" => "1" } },
73
+ { "resource" => { "resourceType" => "Patient", "id" => "2" } }
74
+ ]
75
+ }
76
+ FHIRPath.evaluate(bundle, "entry.resource.ofType(Observation)", model: :r4) # => [Observation resource]
62
77
  ```
63
78
 
64
79
  FHIR R4 JSON can be selected explicitly through the versioned provider. The
@@ -145,6 +160,7 @@ The current tested slice includes:
145
160
  - plain Ruby Hash/Array and simple object navigation, including resource-type roots such as `Patient`;
146
161
  - dependency-free FHIR R4 model navigation selected with `model: :r4`, including the logical `Observation.value` choice property over `valueQuantity` and `valueString`;
147
162
  - unary and numeric arithmetic (`+`, `-`, `*`, `/`, `div`, and `mod`), plus string `+` when both operands are strings; a zero divisor for `/`, `div`, `mod` returns an empty collection, while `+`, `-`, `*` treat zero as a normal operand;
163
+ - bounded FHIRPath `Quantity` values with finite Decimal storage, case-sensitive conversion for the documented dependency-free unit subset, same-dimension comparison/addition/subtraction, scalar multiplication/division, Quantity ratios, and `ofType(Quantity)`/`is Quantity`/`as Quantity`; unsupported units and complete UCUM semantics remain outside this release slice; the parser also accepts double-quoted strings as an extension, uses `^` for supported unit exponents, preserves Quantity `system`/`code` metadata without using it for unit equality, and defers derived-unit composition such as Quantity×Quantity or `km/h`;
148
164
  - numeric/string relational comparison, collection-aware equality, equivalence, and empty-aware Boolean operators; a finite JSON `Float` is treated as a `Decimal`;
149
165
  - union, string concatenation (`+` and `&`), membership (`in`/`contains`), and type operators (`is`/`as`); union removes duplicate values from both operands using `=` equality in first-seen order, and `in`/`contains` require a singleton operand; `is`/`as` test built-in primitive types directly and, with `model: :r4`, also resolve the FHIR logical type of a navigated choice value (for example `Observation.value is Quantity` over `valueQuantity`), returning the value unchanged on a successful `as` and the empty collection otherwise; and
150
166
  - indexers with non-negative integer indexes;
@@ -162,7 +178,7 @@ This is not yet a complete FHIRPath engine. The following remain deferred or hos
162
178
 
163
179
  - complete FHIRPath 2.0 conformance; the checked-in importer covers only the pinned official subset;
164
180
  - broader FHIR R4 metadata such as primitive extensions, resource-level type tests (`Observation is Resource`/`DomainResource`), `ofType()`, and FHIR R5 model adapters; FHIR R4 `is`/`as` over a resolved choice value's logical type (e.g. `Quantity`) is supported, but terminology and `resolve()` remain host-dependent; and
165
- - date/time and quantity/UCUM values;
181
+ - temporal arithmetic with Date/Time/DateTime and Quantity/Duration, including calendar-duration arithmetic;
166
182
  - advanced conversion, math, string, regular-expression, and navigation functions, and the general-purpose `aggregate()` function;
167
183
  - complex literals and additional standard value types;
168
184
  - standard environment variables beyond explicitly supplied external constants;
@@ -1,15 +1,10 @@
1
1
  {
2
2
  "schema": "fhirpath-conformance-v1",
3
3
  "suite": "FHIR/fhir-test-cases",
4
- "suite_commit": "2d595a506a91eeb311ab787f5fe2c8806be3cfd1",
4
+ "suite_commit": "1.7.69",
5
5
  "target": "2.0.0",
6
6
  "model": "plain",
7
7
  "source": "r4/fhirpath/tests-fhir-r4.xml",
8
8
  "fixture_root": "r4",
9
- "cases": [
10
- "testPrecedence2",
11
- "testUnion1",
12
- "testPlus4",
13
- "testLiteralTrue"
14
- ]
15
- }
9
+ "load_full_suite": true
10
+ }
data/docs/api.md CHANGED
@@ -63,6 +63,8 @@ result = FHIRPath.evaluate(
63
63
 
64
64
  Returns a `FHIRPath::Collection`. Empty results are collections with `empty? == true`, not `nil`. `to_a` returns a copy of the ordered values.
65
65
 
66
+ The `resource` argument may be an already-parsed Hash/Array (used directly, never re-serialized) or a raw JSON document String. Strings that open with `{` or `[` after leading whitespace are parsed with `JSON.parse` once per call; a malformed document raises `FHIRPath::JSONInputError` (code `:invalid_json`) with a generic public message that does not echo document contents, while `original_cause` retains the underlying `JSON::ParserError` for programmatic diagnostics. Any other String — plain text, JSON scalar text such as `"null"` or `"123"`, or a quoted JSON primitive like `"\"Ada\""` — keeps its pre-existing meaning as a singleton FHIRPath string value and is never parsed. Parsing builds a fresh structure per call; the caller's String and any Hash/Array resource are never mutated or frozen. `CompiledExpression#evaluate` and `#call` apply the same resource handling.
67
+
66
68
  `variables:` supplies external constants using either String or Symbol keys:
67
69
 
68
70
  ```ruby
@@ -101,6 +103,7 @@ All public engine errors derive from `FHIRPath::Error` and carry a stable symbol
101
103
  | Error | Meaning |
102
104
  |---|---|
103
105
  | `ParseError` | Invalid token, malformed syntax, unsupported escape, trailing input, or expression nesting exceeding the parser depth budget (code `nesting_depth_exceeded`) |
106
+ | `JSONInputError` | The resource argument opened like a JSON object or array (after whitespace) but was not valid JSON (code `invalid_json`) |
104
107
  | `EvaluationError` | Valid syntax cannot be evaluated for the current input |
105
108
  | `SingletonError` | A singleton value was required but the collection had multiple items |
106
109
  | `FHIRPath::TypeError` | A value has an incompatible FHIRPath type |
data/docs/conformance.md CHANGED
@@ -5,7 +5,8 @@ The project distinguishes specification conformance from compatibility evidence.
5
5
  - The HL7 FHIRPath specification and official shared test cases are normative.
6
6
  - Checked-in JSONL vectors are small, reviewable regression probes inspired by observed behavior in `fhirpath-py`.
7
7
  - No Python runtime is required to run the Ruby vector harness.
8
- - The repository ships a Ruby-only importer for pinned official XML subsets and the pinned `fhirpath-py` YAML case format; complete conformance remains deferred.
8
+ - The repository ships a Ruby-only importer for official XML suites and the pinned `fhirpath-py` YAML case format.
9
+ - The default manifest imports a pinned subset; `load_full_suite: true` imports the complete official suite.
9
10
 
10
11
  ## Run the checked-in vectors
11
12
 
@@ -52,6 +53,23 @@ Values that JSON cannot represent should use explicit tagged values when that fa
52
53
  4. Add the JSONL vector and run the complete test, lint, build, and vector commands.
53
54
  5. Update `docs/feature-matrix.md`, README limitations, and `CHANGELOG.md` if the public scope changes.
54
55
 
56
+ ## Import the complete official suite
57
+
58
+ Set `load_full_suite: true` in the manifest to bypass `case_ids` filtering and import every XML case from the official suite. The importer still applies the same fixture normalization, provenance tracking, and `not-run` classification rules as the pinned-subset mode.
59
+
60
+ ```json
61
+ {
62
+ "schema": "fhirpath-conformance-v1",
63
+ "suite": "FHIR/fhir-test-cases",
64
+ "suite_commit": "1.7.69",
65
+ "target": "2.0.0",
66
+ "model": "plain",
67
+ "source": "r4/fhirpath/tests-fhir-r4.xml",
68
+ "fixture_root": "r4",
69
+ "load_full_suite": true
70
+ }
71
+ ```
72
+
55
73
  ## Import a pinned suite subset
56
74
 
57
75
  The importer accepts the checked-in manifest and a local checkout of its pinned source:
@@ -10,22 +10,25 @@ This matrix is deliberately conservative. `Supported` means the behavior is exer
10
10
  | Parse, immutable AST, source spans | Supported | foundation/parser tests |
11
11
  | Complete-input validation | Supported | parser regression tests |
12
12
  | String, Boolean, integer, decimal literals | Supported | foundation/core compatibility tests |
13
+ | Double-quoted string literals | Extension | the lexer accepts double-quoted strings in addition to normative single-quoted FHIRPath strings; this extension supports double-quoted Quantity units and is covered by Quantity tests |
13
14
  | Scientific notation | Supported | core compatibility tests |
14
15
  | Empty and comma-separated collections | Supported | parser/evaluator tests |
15
- | Hash/Array/plain object navigation | Supported | foundation tests; `PlainModel` |
16
- | Unary/numeric arithmetic and string `+` | Supported | parity/core compatibility tests; `+` propagates empty operands; a zero divisor for `/`, `div`, `mod` yields an empty collection, while `+`, `-`, `*` operate on zero normally |
16
+ || Hash/Array/plain object navigation | Supported | foundation tests; `PlainModel` ||
17
+ || Raw JSON string resource input | Supported | `test/json_string_input_test.rb`; `FHIRPath.evaluate`, `FHIRPath.evaluate_first`, and `CompiledExpression#normalize_resource` normalize strings opening with `{` or `[` after whitespace via `JSON.parse` once per call, malformed documents raise `JSONInputError` code `invalid_json`, Hash/Array inputs are never parsed, and other strings (plain text, JSON scalar text, quoted primitives) pass through unchanged ||
18
+ || Unary/numeric arithmetic and string `+` | Supported | parity/core compatibility tests; `+` propagates empty operands; a zero divisor for `/`, `div`, `mod` yields an empty collection, while `+`, `-`, `*` operate on zero normally ||
17
19
  | Relational comparison | Supported | parity/core compatibility tests |
18
20
  | Collection equality/equivalence | Supported | core compatibility tests and vectors |
19
21
  | Finite JSON `Float` treated as `Decimal` | Supported | evaluator correctness tests; a finite `Float` (e.g. from `JSON.parse`) compares, equals, arithmetically combines, and satisfies `is Decimal`; `NaN`/`Infinity` are rejected as non-numeric |
20
22
  | String `&` concatenation | Supported | core compatibility tests; empty operands are treated as `''` |
21
23
  | Empty-aware Boolean operators | Supported | foundation/core compatibility tests |
22
24
  | Union, `in`, `contains`, `is`, `as` | Supported | core compatibility tests and vectors; union eliminates duplicates from both operands using `=` equality in first-seen order; `in`/`contains` require a singleton operand and follow the empty-collection rules; `is`/`as` test built-in primitive types by runtime value and, when a model provider resolves the value, also test the FHIR logical type recorded by navigation (e.g. `Observation.value is Quantity`), with `as` passing the value through unchanged on a match and yielding the empty collection on a mismatch |
23
- | Indexers | Supported | foundation/parity tests |
24
- | `where`, `select`, `first`, `last`, `tail`, `take`, `skip`, `exists` | Supported | `test/subsetting_functions_test.rb` |
25
- | Aggregate functions `count()`, `sum()`, `avg()`, `max()`, `min()` | Supported | `test/aggregate_functions_test.rb` and aggregate vectors. `count()` follows FHIRPath 2.0.0 (integer count; empty -> `[0]`). `sum`/`avg`/`max`/`min` are FHIRPath 3.0.0 STU3 aggregate additions (published 2026-07-28; absent from 2.0.0 and the 3.0.0 ballot) shipped in the standard registry: empty input -> empty; `sum()`/`avg()` accept numeric items only (`TypeError` code `expected_number` otherwise), sum mixed Integer/Decimal input through Decimal, and `avg()` converts Integer items to Decimal before dividing; `max()`/`min()` use comparison-operator semantics for numeric and string items (incompatible item types raise `TypeError` code `incompatible_comparison`); no input mutation. The STU3 subset is surfaced on the capability object: `Capability.current` keeps `fhirpath` `2.0.0` and declares marker `stu3-aggregate-functions` in `trial_use` (capability surface tests in the same file) |
26
- | `empty`, `not`, `all`, Boolean aggregates | Supported | core compatibility tests |
27
- | `$this`, `$index`, `$total` | Supported | parity tests |
28
- | Explicit external constants | Supported | foundation/core compatibility tests; values may come from `variables:` or an explicitly injected `HostServices` constant provider |
25
+ || Indexers | Supported | foundation/parity tests |
26
+ || `where`, `select`, `first`, `last`, `tail`, `take`, `skip`, `exists` | Supported | `test/subsetting_functions_test.rb` |
27
+ || Aggregate functions `count()`, `sum()`, `avg()`, `max()`, `min()` | Supported | `test/aggregate_functions_test.rb` and aggregate vectors. `count()` follows FHIRPath 2.0.0 (integer count; empty -> `[0]`). `sum`/`avg`/`max`/`min` are FHIRPath 3.0.0 STU3 aggregate additions (published 2026-07-28; absent from 2.0.0 and the 3.0.0 ballot) shipped in the standard registry: empty input -> empty; `sum()`/`avg()` accept numeric items only (`TypeError` code `expected_number` otherwise), sum mixed Integer/Decimal input through Decimal, and `avg()` converts Integer items to Decimal before dividing; `max()`/`min()` use comparison-operator semantics for numeric and string items (incompatible item types raise `TypeError` code `incompatible_comparison`); no input mutation. The STU3 subset is surfaced on the capability object: `Capability.current` keeps `fhirpath` `2.0.0` and declares marker `stu3-aggregate-functions` in `trial_use` (capability surface tests in the same file) |
28
+ || `empty`, `not`, `all`, Boolean aggregates | Supported | core compatibility tests |
29
+ || `$this`, `$index`, `$total` | Supported | parity tests |
30
+ | General-purpose `aggregate()` function | Supported | `test/aggregate_functions_test.rb`; enables custom aggregations via `$this`, `$index`, `$total` |
31
+ || Explicit external constants | Supported | foundation/core compatibility tests; values may come from `variables:` or an explicitly injected `HostServices` constant provider |
29
32
  | Missing external constant provider | Supported | `test/host_services_test.rb`; raises `UnknownConstantError` with code `:unknown_constant` and performs no fallback I/O |
30
33
  | Constant-provider failures and redaction | Supported | `test/host_services_test.rb`; raises generic `HostError` without retaining constant-provider exceptions as public causes or exposing their detail in diagnostics |
31
34
  | Host callback configuration/reentrancy | Supported | `test/host_services_test.rb`; `HostServices` is immutable and each evaluation receives a fresh context |
@@ -38,9 +41,9 @@ This matrix is deliberately conservative. `Supported` means the behavior is exer
38
41
  | Temporal component extractors (`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`) | Supported | `test/temporal_components_test.rb`; `millisecond()` on `DateTime` reads `sec_fraction * 1000` |
39
42
  | Temporal timezone (`timezone()`, `timezoneOffset()`) | Supported | `test/temporal_components_test.rb` |
40
43
  | Temporal same-type comparison | Supported | `test/temporal_comparison_test.rb`; cross-type raises `incompatible_comparison` |
41
- | Temporal arithmetic with Quantity/Duration | Deferred | no Quantity type yet |
44
+ | Temporal arithmetic with Quantity/Duration | Deferred | calendar-duration and date/time arithmetic is not implemented; bounded numeric Quantity arithmetic is supported separately below |
42
45
  | FHIR primitive extension accessor (`._<name>`) | Supported | `test/primitive_extensions_test.rb`; returns the underlying `{value, extension}` container or empty per FHIRPath 2.0.0 |
43
- | Quantity/UCUM | Deferred | no unit service or quantity implementation |
46
+ | Quantity/UCUM | Supported | `test/quantity_test.rb` and `test/quantity_edge_cases_test.rb`; immutable Decimal-backed quantities, case-sensitive dimensional conversion for the explicitly bounded dependency-free subset (`m`/`cm`/`mm`/`km`, `g`/`kg`/`mg`/`Mg`/`ug`/`ng`, `L`/`mL`/`ML`/`uL`, `mol`/`mmol`/`umol`, `s`/`min`/`h`, and products/quotients such as `mmol/L`); unsupported units are rejected rather than treated as dimensionless, incompatible calculations and mixed Quantity/scalar addition return empty, same-dimension Quantity division returns a Decimal ratio, derived-unit composition such as Quantity×Quantity or `km/h` remains deferred, and system/code metadata is preserved without changing unit equality |
44
47
  | Advanced conversion/math/string/regex | Deferred | not in standard registry |
45
48
  | FHIR R4 model adapter (`model: :r4`) | Supported | `test/r4_model_test.rb`; dependency-free `FHIRPath::FHIR::R4::ModelProvider` |
46
49
  | FHIR R4 `Observation.value[x]` logical navigation | Supported | R4 choice vectors; `valueQuantity` and `valueString` resolve through `value`, absent choice is empty |
@@ -34,12 +34,15 @@ These stable identifiers describe the behavior included in the current package:
34
34
  - `collection-evaluation` — explicit empty, singleton, and multi-item results;
35
35
  - `plain-model-navigation` — Hash, Array, and safe simple-object navigation;
36
36
  - `primitive-values` — strings, Booleans, integers, decimals, and scientific notation;
37
- - `arithmetic` — unary/numeric arithmetic and string `+`;
37
+ - `arithmetic` — unary/numeric arithmetic, string `+`, and the bounded Quantity scalar/Quantity arithmetic described below;
38
+ - `quantity-ucum` — immutable Decimal-backed Quantity values with case-sensitive dimensional conversion for the explicitly supported unit subset (`m`/`cm`/`mm`/`km`, `g`/`kg`/`mg`/`Mg`/`ug`/`ng`, `L`/`mL`/`ML`/`uL`, `mol`/`mmol`/`umol`, `s`/`min`/`h`, and products/quotients such as `mmol/L`); unsupported units are rejected, incompatible calculations and mixed Quantity/scalar addition return empty, same-dimension Quantity division returns a Decimal ratio, derived-unit composition such as Quantity×Quantity or `km/h` remains deferred, and system/code metadata is preserved without changing unit equality;
38
39
  - `comparison-and-equivalence` — relational comparison, equality, and equivalence;
39
40
  - `boolean-logic` — empty-aware Boolean operators;
40
41
  - `union-membership-and-type-operators` — union, `in`, `contains`, `is`, and `as`;
41
42
  - `collection-functions` — `where`, `select`, `first`, `exists`, `count`, `empty`, `not`, `all`, Boolean aggregates, and `ofType`;
43
+ + `collection-functions` — `where`, `select`, `first`, `exists`, `count`, `empty`, `not`, `all`, Boolean aggregates, `ofType`, and `aggregate`;
42
44
  - `subsetting-functions` — `tail`, `take`, `skip`, `last`;
45
+ + `subsetting-functions` — `tail`, `take`, `skip`, `last`;
43
46
  - `temporal-types` — `Date`, `DateTime`, `Time` literals (`@YYYY-MM-DD`, `@THH:MM:SS`, `@YYYY-MM-DDTHH:MM:SS±HH:MM`), `today()`, `now()`, `time()`, component extractors (`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`), `timezone()`, `timezoneOffset()`, and same-type comparison;
44
47
  - `fhir-primitive-extensions` — `._<name>` accessor returning the underlying `{value, extension}` container or empty per FHIRPath 2.0.0;
45
48
  - `focus-variables` — `$this`, `$index`, and `$total`;
@@ -75,14 +78,64 @@ behavioral evidence and [`docs/api.md`](api.md) for the runtime declaration.
75
78
  The release must continue to state these limitations:
76
79
 
77
80
  - complete official HL7 shared-suite conformance and its importer;
78
- - date/time literals and values;
79
- - quantity and UCUM semantics;
80
- - advanced conversion, math, string, regular-expression, and navigation functions, and the general-purpose `aggregate()` function;
81
+ - complete UCUM conformance, units outside the explicitly supported subset, derived-unit composition such as `60 'km' / 1 'h'`, `Quantity × Quantity`, calendar-duration arithmetic, and temporal arithmetic with Quantity;
82
+ - `subsetOf`, `startsWith`, `endsWith`, `matches`, `matchesFull`, `convertsToInteger`, `lowBoundary`, `highBoundary`, `comparable`, `precision`, and `hasValue` (including the broader temporal/UCUM behavior those official cases exercise);
83
+ - advanced conversion, math, string, regular-expression, and navigation functions beyond the explicitly listed implementations;
81
84
  - complex literals and additional standard value types;
82
85
  - standard environment variables beyond explicitly supplied external constants;
83
86
  - FHIRPath `3.0` STU3 features beyond the declared `stu3-aggregate-functions` subset; and
84
87
  - network I/O and global evaluator state in the pure evaluation boundary.
85
88
 
89
+ ## Official shared-suite classification snapshot
90
+
91
+ The full checked-in R4 suite was imported and executed on 2026-09-07 from
92
+ FHIR/fhir-test-cases commit `1.7.69` with the command sequence:
93
+
94
+ ```sh
95
+ ruby script/import_vectors.rb conformance conformance/official-r4-core.json > /tmp/fhirpath-official-full.jsonl
96
+ ruby script/run_vectors.rb /tmp/fhirpath-official-full.jsonl
97
+ ```
98
+
99
+ The run produced 935 records; the full machine-readable report is checked in at
100
+ `conformance/official-suite-report.json`. The runner's mechanical counts were:
101
+
102
+ | Raw runner classification | Count | Manual disposition |
103
+ |---|---:|---|
104
+ | `pass` | 4 | passing evidence |
105
+ | `defect` | 83 | 0 genuine parser defects (#66 resolved); 81 known unsupported/deferred behaviors; 1 expected-execution-failure (#65) |
106
+ | `unsupported` | 0 | the current runner reports missing standard functions as raw defects when no expected error is declared |
107
+ | `host-dependent` | 0 | no host-service cases reached evaluation |
108
+ | `not-run` | 848 | fixture/import limitation: no verified matching JSON fixture for the source XML |
109
+
110
+ The 81 known unsupported/deferred cases are grouped as follows: `subsetOf`
111
+ (1), `startsWith` (1), `endsWith` (1), regular-expression `matches` and
112
+ `matchesFull` (16), `convertsToInteger` (1), `lowBoundary` (28), `highBoundary`
113
+ (24), `comparable` and the broader UCUM units it exercises (3), `precision`
114
+ (5), and `hasValue` (1). These remain outside the published capability set and
115
+ are covered by the limitations below; they are not evidence of regressions in
116
+ supported behavior.
117
+
118
+ One case is a genuine Ruby implementation defect and has a separate bug report:
119
+ failure to parse the valid keyword-named function call
120
+ `Appointment.identifier.contains('rand')` ([#66](https://github.com/niccoreyes/fhirpath-ruby/issues/66)), which is a lexer/parser
121
+ collision between the `contains` binary operator and the `contains()` function
122
+ name, independent of whether the string-function family is enabled. This was
123
+ resolved by teaching the parser to treat keyword operators (`contains`, `in`)
124
+ as member-invoked function names after a `.`.
125
+
126
+ The `1 > 2 is Boolean` case ([#65](https://github.com/niccoreyes/fhirpath-ruby/issues/65)) is not a defect: the
127
+ official suite marks `testPrecedence3` `invalid="execution"`, so the expression
128
+ is expected to fail at evaluation time. With `is`/`as` binding tighter than the
129
+ relational comparison, `1 > 2 is Boolean` parses as `1 > (2 is Boolean)`, which
130
+ correctly raises a `TypeError` — matching the intended suite behavior.
131
+
132
+ The 848 `not-run` records are not silently treated as passes. They identify
133
+ where the official XML suite currently lacks a verified same-resource JSON
134
+ fixture in the checked-in fixture set; improving fixture coverage is separate
135
+ from evaluator conformance. Until those records are resolved or explicitly
136
+ accepted as fixture limitations, the official suite cannot be used as a green
137
+ release gate.
138
+
86
139
  ## Host-dependent behavior
87
140
 
88
141
  The gem bundles the dependency-free R4 JSON adapter described above. Callers
@@ -8,7 +8,7 @@ module FHIRPath
8
8
  primitive-values arithmetic comparison-and-equivalence boolean-logic
9
9
  union-membership-and-type-operators collection-functions focus-variables
10
10
  external-constants custom-functions compiled-expression-reuse fhir-r4-model
11
- structured-errors
11
+ structured-errors quantity-ucum aggregate
12
12
  ].freeze
13
13
 
14
14
  # Named FHIRPath 3.0.0 STU3 subset the standard registry ships by default.
@@ -37,7 +37,7 @@ module FHIRPath
37
37
 
38
38
  def supports?(feature)
39
39
  trial_use.include?(feature.to_s) || host_features.include?(feature.to_s) ||
40
- supports_model?(feature)
40
+ capability_set.include?(feature.to_s) || supports_model?(feature)
41
41
  end
42
42
 
43
43
  def supports_model?(release)
@@ -16,6 +16,7 @@ module FHIRPath
16
16
  end
17
17
 
18
18
  def evaluate(resource, variables: {}, host: nil, options: {})
19
+ resource = normalize_resource(resource)
19
20
  context = EvaluationContext.new(
20
21
  root: resource,
21
22
  variables: variables,
@@ -31,5 +32,43 @@ module FHIRPath
31
32
  def call(resource, **kwargs)
32
33
  evaluate(resource, **kwargs)
33
34
  end
35
+
36
+ private
37
+
38
+ # Raw JSON document strings (the common HTTP-body shape for FHIR) are
39
+ # parsed once per call so Hash/Array resources never pay a JSON cost and
40
+ # the caller's String is left untouched. Detection is deliberately limited
41
+ # to text that opens with `{` or `[` after whitespace; any other String --
42
+ # plain text, JSON scalar text such as "null" or "123", quoted primitives
43
+ # -- keeps its pre-existing meaning as a singleton FHIRPath string value.
44
+ def normalize_resource(resource)
45
+ return resource unless resource.is_a?(String)
46
+
47
+ document = resource.dup
48
+ document = document.force_encoding(Encoding::UTF_8) if document.encoding == Encoding::ASCII_8BIT
49
+ unless document.valid_encoding?
50
+ if ascii_json_document?(resource)
51
+ raise JSONInputError.new('resource string is not valid JSON', expression: source)
52
+ end
53
+
54
+ return resource
55
+ end
56
+ document = document.encode(Encoding::UTF_8)
57
+ document = document.lstrip
58
+ document = document.delete_prefix("\uFEFF").lstrip
59
+ return resource unless document.start_with?('{', '[')
60
+
61
+ JSON.parse(document)
62
+ rescue JSON::ParserError => e
63
+ raise JSONInputError.new(
64
+ 'resource string is not valid JSON',
65
+ cause: e, expression: source
66
+ )
67
+ end
68
+
69
+ def ascii_json_document?(resource)
70
+ index = resource.bytes.index { |byte| ![9, 10, 13, 32].include?(byte) }
71
+ index && [91, 123].include?(resource.getbyte(index))
72
+ end
34
73
  end
35
74
  end
@@ -7,9 +7,8 @@ require_relative '../capability'
7
7
 
8
8
  module FHIRPath
9
9
  module Conformance
10
- # Imports a selected, pinned subset of the official XML shared suite.
11
- # The importer is deliberately Ruby-only so it is safe to use in tests and
12
- # release checks without installing a second language runtime.
10
+ # Imports test cases from the official HL7 FHIRPath test suite.
11
+ # Can load either a pinned subset or the complete suite.
13
12
  class Importer
14
13
  DEFAULT_SUITE = 'FHIR/fhir-test-cases'
15
14
 
@@ -25,12 +24,14 @@ module FHIRPath
25
24
  target: manifest.fetch('target'),
26
25
  model: manifest.fetch('model', 'plain'),
27
26
  host_features: manifest.fetch('host_features', Capability.current.host_features),
28
- case_ids: manifest['cases']
27
+ case_ids: manifest['case_ids'],
28
+ load_full_suite: manifest.fetch('load_full_suite', false)
29
29
  )
30
30
  end
31
31
 
32
32
  def initialize(source_root:, suite_path:, suite_commit:, suite: DEFAULT_SUITE, target: '2.0.0',
33
- fixture_root: nil, model: 'plain', host_features: Capability.current.host_features, case_ids: nil)
33
+ fixture_root: nil, model: 'plain', host_features: Capability.current.host_features,
34
+ case_ids: nil, load_full_suite: false)
34
35
  @source_root = File.expand_path(source_root)
35
36
  @suite_path = relative_path(suite_path)
36
37
  @fixture_root = fixture_root && relative_path(fixture_root)
@@ -40,6 +41,7 @@ module FHIRPath
40
41
  @model = model.to_s.freeze
41
42
  @host_features = Array(host_features).map(&:to_s).freeze
42
43
  @case_ids = case_ids&.map(&:to_s)&.freeze
44
+ @load_full_suite = load_full_suite
43
45
  validate_options
44
46
  end
45
47
 
@@ -61,11 +63,12 @@ module FHIRPath
61
63
  def import_xml
62
64
  document = REXML::Document.new(File.read(absolute_path(@suite_path)))
63
65
  tests = document.root.elements.to_a('group/test')
64
- select_tests(tests).map { |test| record_for(test) }
66
+ tests_to_process = @load_full_suite ? tests : select_tests(tests)
67
+ tests_to_process.map { |test| record_for(test) }
65
68
  end
66
69
 
67
70
  def import_yaml
68
- document = YAML.safe_load_file(absolute_path(@suite_path), permitted_classes: [], aliases: false)
71
+ document = YAML.safe_load(File.read(absolute_path(@suite_path)), permitted_classes: [], aliases: false) # rubocop:disable Style/YAMLFileRead
69
72
  subject = deep_copy(document['subject']) if document.is_a?(Hash)
70
73
  ordinal = 0
71
74
  records = []
@@ -98,7 +101,7 @@ module FHIRPath
98
101
  end
99
102
 
100
103
  def select_tests(tests)
101
- return tests unless @case_ids
104
+ return tests unless @case_ids && !@load_full_suite
102
105
 
103
106
  by_id = tests.to_h { |test| [test.attributes.fetch('name').to_s, test] }
104
107
  @case_ids.map do |case_id|
@@ -26,6 +26,14 @@ module FHIRPath
26
26
  end
27
27
  end
28
28
 
29
+ # The resource argument to an evaluate boundary was a String that opens like
30
+ # a JSON object or array (after leading whitespace) but is not valid JSON.
31
+ class JSONInputError < Error
32
+ def initialize(message = 'resource string is not valid JSON', code: :invalid_json, **kwargs)
33
+ super(message, code: code, **kwargs)
34
+ end
35
+ end
36
+
29
37
  class EvaluationError < Error
30
38
  def initialize(message = 'expression could not be evaluated', code: :evaluation_error, **kwargs)
31
39
  super(message, code: code, **kwargs)
@@ -54,7 +54,11 @@ module FHIRPath
54
54
  when 'index'
55
55
  context.index.nil? ? Collection.empty : Collection.new([context.index])
56
56
  when 'total'
57
- context.total.nil? ? Collection.empty : Collection.new([context.total])
57
+ if context.variables.key?('total')
58
+ context.variables['total']
59
+ else
60
+ context.total.nil? ? Collection.empty : Collection.new([context.total])
61
+ end
58
62
  else
59
63
  unless context.variables.key?(node.name)
60
64
  raise EvaluationError.new("unknown variable: $#{node.name}",
@@ -215,6 +219,12 @@ module FHIRPath
215
219
  boolean_aggregate(receiver, node.name, node)
216
220
  when 'ofType'
217
221
  of_type(receiver, node, context)
222
+ when 'in'
223
+ # `in` as a function: receiver.in(collection) -> receiver in collection
224
+ membership_value(receiver, node.arguments.first, context)
225
+ when 'contains'
226
+ # `contains` as a function: receiver.contains(value) -> receiver contains value
227
+ membership_collection(receiver, node.arguments.first, context)
218
228
  when 'today'
219
229
  temporal_now(receiver, node, context, :date)
220
230
  when 'now'
@@ -225,6 +235,10 @@ module FHIRPath
225
235
  temporal_component(receiver, node, context, node.name)
226
236
  when 'timezone', 'timezoneOffset'
227
237
  temporal_timezone(receiver, node, context, node.name)
238
+ when 'aggregate'
239
+ aggregate(receiver, node, context)
240
+ when 'iif'
241
+ iif(receiver, node, context)
228
242
  else
229
243
  invoke_registered(node, receiver, context, spec)
230
244
  end
@@ -303,6 +317,51 @@ module FHIRPath
303
317
  Collection.new([decimal(total) / receiver.count])
304
318
  end
305
319
 
320
+ def iif(_receiver, node, context)
321
+ condition_expr = node.arguments[0]
322
+ true_expr = node.arguments[1]
323
+ false_expr = node.arguments[2]
324
+
325
+ cond_result = evaluate(condition_expr, context)
326
+ is_true = boolean_value(cond_result, condition_expr.span) == true
327
+
328
+ if is_true
329
+ true_expr ? evaluate(true_expr, context) : Collection.empty
330
+ else
331
+ false_expr ? evaluate(false_expr, context) : Collection.empty
332
+ end
333
+ end
334
+
335
+ def aggregate(receiver, node, context)
336
+ aggregator_expr = node.arguments.first
337
+ init_expr = node.arguments[1]
338
+
339
+ total = if init_expr
340
+ evaluate(init_expr, context)
341
+ else
342
+ Collection.empty
343
+ end
344
+
345
+ return total if receiver.empty?
346
+
347
+ receiver.items.each_with_index do |item, index|
348
+ item_col = Collection.new([item])
349
+ iter_context = context.derive(
350
+ focus: item_col,
351
+ index: index,
352
+ total: receiver.count,
353
+ variables: context.variables.merge(
354
+ 'this' => item_col,
355
+ 'total' => total
356
+ )
357
+ )
358
+
359
+ total = evaluate(aggregator_expr, iter_context)
360
+ end
361
+
362
+ total
363
+ end
364
+
306
365
  def extremum(receiver, mode, node)
307
366
  return Collection.empty if receiver.empty?
308
367
 
@@ -387,6 +446,11 @@ module FHIRPath
387
446
  return Collection.empty if value.empty?
388
447
 
389
448
  number = require_singleton(value, node.operand.span)
449
+ if number.is_a?(Quantity)
450
+ result = node.operator == :minus ? -number.value : number.value
451
+ return Collection.new([Quantity.new(value: result, unit: number.unit,
452
+ system: number.system, code: number.code)])
453
+ end
390
454
  unless numeric?(number)
391
455
  raise TypeError.new('unary arithmetic requires a number', code: :expected_number,
392
456
  span: node.span)
@@ -493,6 +557,9 @@ module FHIRPath
493
557
 
494
558
  left_value = require_singleton(left, node.left.span)
495
559
  right_value = require_singleton(right, node.right.span)
560
+ if left_value.is_a?(Quantity) || right_value.is_a?(Quantity)
561
+ return quantity_arithmetic(left_value, right_value, node.operator, node.span)
562
+ end
496
563
  if left_value.is_a?(::String) && right_value.is_a?(::String) && node.operator == :plus
497
564
  return Collection.new([left_value + right_value])
498
565
  end
@@ -527,6 +594,51 @@ module FHIRPath
527
594
  Collection.empty
528
595
  end
529
596
 
597
+ def quantity_arithmetic(left, right, operator, span)
598
+ if left.is_a?(Quantity) && right.is_a?(Quantity)
599
+ return Collection.empty unless left.compatible?(right)
600
+
601
+ converted = right.convert_to(left.unit)
602
+ return Collection.empty if operator == :divide && converted.value.zero?
603
+
604
+ result = case operator
605
+ when :plus then Quantity.new(value: left.value + converted.value, unit: left.unit,
606
+ system: left.system, code: left.code)
607
+ when :minus then Quantity.new(value: left.value - converted.value, unit: left.unit,
608
+ system: left.system, code: left.code)
609
+ when :divide then decimal(left.value) / decimal(converted.value)
610
+ else return Collection.empty
611
+ end
612
+ return Collection.new([result])
613
+ end
614
+
615
+ if left.is_a?(Quantity) && numeric?(right)
616
+ return Collection.empty if %i[divide div mod].include?(operator) && zero?(right)
617
+ if operator == :multiply
618
+ return Collection.new([Quantity.new(value: left.value * right, unit: left.unit, system: left.system,
619
+ code: left.code)])
620
+ end
621
+ if operator == :divide
622
+ return Collection.new([Quantity.new(value: left.value / right, unit: left.unit, system: left.system,
623
+ code: left.code)])
624
+ end
625
+
626
+ return Collection.empty
627
+ end
628
+
629
+ if numeric?(left) && right.is_a?(Quantity)
630
+ if operator == :multiply
631
+ return Collection.new([Quantity.new(value: right.value * left, unit: right.unit,
632
+ system: right.system, code: right.code)])
633
+ end
634
+
635
+ return Collection.empty
636
+ end
637
+
638
+ raise TypeError.new('Quantity arithmetic requires a compatible Quantity or scalar',
639
+ code: :incompatible_quantity, span: span)
640
+ end
641
+
530
642
  def comparison(node, context)
531
643
  left = evaluate(node.left, context)
532
644
  right = evaluate(node.right, context)
@@ -534,7 +646,15 @@ module FHIRPath
534
646
 
535
647
  left_value = require_singleton(left, node.left.span)
536
648
  right_value = require_singleton(right, node.right.span)
537
- comparison = compare_values(left_value, right_value, node.span)
649
+ if left_value.is_a?(Quantity) || right_value.is_a?(Quantity)
650
+ return Collection.empty unless left_value.is_a?(Quantity) && right_value.is_a?(Quantity)
651
+ return Collection.empty unless left_value.compatible?(right_value)
652
+
653
+ right_value = right_value.convert_to(left_value.unit)
654
+ comparison = left_value.value <=> right_value.value
655
+ else
656
+ comparison = compare_values(left_value, right_value, node.span)
657
+ end
538
658
  result = case node.operator
539
659
  when :less_than then comparison.negative?
540
660
  when :less_or_equal then comparison <= 0
@@ -595,6 +715,30 @@ module FHIRPath
595
715
  Collection.new([left.items.any? { |candidate| equal?(candidate, value) }])
596
716
  end
597
717
 
718
+ # `in` as a function: receiver.in(collection) checks if the singleton receiver
719
+ # is a member of the argument collection.
720
+ def membership_value(receiver, argument_node, context, receiver_span = nil)
721
+ argument = evaluate(argument_node, context)
722
+ return Collection.empty if receiver.empty?
723
+
724
+ value = require_singleton(receiver, receiver_span || argument_node.span)
725
+ return Collection.new([false]) if argument.empty?
726
+
727
+ Collection.new([argument.items.any? { |candidate| equal?(value, candidate) }])
728
+ end
729
+
730
+ # `contains` as a function: receiver.contains(value) checks if the argument
731
+ # singleton is a member of the receiver collection.
732
+ def membership_collection(receiver, argument_node, context)
733
+ argument = evaluate(argument_node, context)
734
+ return Collection.empty if argument.empty?
735
+
736
+ value = require_singleton(argument, argument_node.span)
737
+ return Collection.new([false]) if receiver.empty?
738
+
739
+ Collection.new([receiver.items.any? { |candidate| equal?(candidate, value) }])
740
+ end
741
+
598
742
  def of_type(receiver, node, _context)
599
743
  # FHIRPath ofType() filters the collection to items matching the specified type
600
744
  # The argument is a type identifier (e.g., Integer, String, Decimal, Boolean,
@@ -728,6 +872,7 @@ module FHIRPath
728
872
  when 'date' then value.is_a?(Date)
729
873
  when 'datetime' then value.is_a?(DateTime)
730
874
  when 'time' then value.is_a?(Time)
875
+ when 'quantity' then value.is_a?(Quantity)
731
876
  else false
732
877
  end
733
878
  end
@@ -16,12 +16,13 @@ module FHIRPath
16
16
  where select first last tail take skip ofType
17
17
  exists count empty not all
18
18
  allTrue anyTrue allFalse anyFalse
19
- sum avg max min
19
+ sum avg max min aggregate iif
20
20
  today now time
21
21
  year month day hour minute second millisecond
22
22
  timezone timezoneOffset
23
+ in contains
23
24
  ].freeze
24
- DELAYED_NAMES = %w[where select exists all].freeze
25
+ DELAYED_NAMES = %w[where select exists all aggregate iif].freeze
25
26
 
26
27
  def self.standard
27
28
  @standard ||= new(STANDARD_NAMES.each_with_object({}) do |name, specs|
@@ -33,19 +34,30 @@ module FHIRPath
33
34
  FunctionSpec.new(
34
35
  name: name,
35
36
  arity: standard_arity(name),
36
- parameters: DELAYED_NAMES.include?(name) ? [:expression] : [],
37
+ parameters: standard_parameters(name),
37
38
  receiver: :collection,
38
39
  delayed: DELAYED_NAMES.include?(name)
39
40
  )
40
41
  end
41
42
 
43
+ def self.standard_parameters(name)
44
+ return [:expression] if %w[where select exists all].include?(name)
45
+ return %i[expression expression expression] if name == 'iif'
46
+ return %i[expression any] if name == 'aggregate'
47
+
48
+ []
49
+ end
50
+
42
51
  def self.standard_arity(name)
43
52
  return 0..1 if name == 'exists'
44
- return 1 if name == 'all' || %w[where select take skip ofType].include?(name)
53
+ return 1 if name == 'all' || %w[where select take skip ofType in contains].include?(name)
54
+ return 1..2 if name == 'aggregate'
55
+ return 2..3 if name == 'iif'
45
56
 
46
57
  0
47
58
  end
48
- private_class_method :standard_arity, :standard_spec
59
+
60
+ private_class_method :standard_arity, :standard_spec, :standard_parameters
49
61
 
50
62
  def initialize(specs = {})
51
63
  @specs = specs.transform_keys(&:to_s).dup.freeze
@@ -19,6 +19,35 @@ module FHIRPath
19
19
  valueInteger valueRange valueRatio valueSampledData valueTime
20
20
  valueDateTime valuePeriod
21
21
  ].freeze
22
+ },
23
+ 'Medication' => {
24
+ 'medication' => %w[
25
+ medicationCodeableConcept medicationReference
26
+ ].freeze
27
+ },
28
+ 'Condition' => {
29
+ 'clinicalStatus' => %w[
30
+ clinicalStatusCodeableConcept clinicalStatusCoding
31
+ ].freeze,
32
+ 'verificationStatus' => %w[
33
+ verificationStatusCodeableConcept verificationStatusCoding
34
+ ].freeze
35
+ },
36
+ 'Procedure' => {
37
+ 'outcome' => %w[
38
+ outcomeCodeableConcept outcomeReference
39
+ ].freeze,
40
+ 'indication' => %w[
41
+ indicationCodeableConcept indicationReference
42
+ ].freeze
43
+ },
44
+ 'DiagnosticReport' => {
45
+ 'category' => %w[
46
+ categoryCodeableConcept categoryCoding
47
+ ].freeze,
48
+ 'code' => %w[
49
+ codeCodeableConcept codeCoding
50
+ ].freeze
22
51
  }
23
52
  }.freeze
24
53
  CHOICE_TYPES = {
@@ -26,6 +55,35 @@ module FHIRPath
26
55
  'value' => %w[
27
56
  Quantity CodeableConcept string boolean integer Range Ratio SampledData time dateTime Period
28
57
  ].freeze
58
+ },
59
+ 'Medication' => {
60
+ 'medication' => %w[
61
+ CodeableConcept Reference
62
+ ].freeze
63
+ },
64
+ 'Condition' => {
65
+ 'clinicalStatus' => %w[
66
+ CodeableConcept Coding
67
+ ].freeze,
68
+ 'verificationStatus' => %w[
69
+ CodeableConcept Coding
70
+ ].freeze
71
+ },
72
+ 'Procedure' => {
73
+ 'outcome' => %w[
74
+ CodeableConcept Reference
75
+ ].freeze,
76
+ 'indication' => %w[
77
+ CodeableConcept Reference
78
+ ].freeze
79
+ },
80
+ 'DiagnosticReport' => {
81
+ 'category' => %w[
82
+ CodeableConcept Coding
83
+ ].freeze,
84
+ 'code' => %w[
85
+ CodeableConcept Coding
86
+ ].freeze
29
87
  }
30
88
  }.freeze
31
89
 
@@ -93,7 +93,7 @@ module FHIRPath
93
93
  @index += 1
94
94
  return token(SINGLE.fetch(char), char, start)
95
95
  end
96
- return string_token(start) if char == "'"
96
+ return string_token(start) if ["'", '"'].include?(char)
97
97
  return external_token(start) if char == '%'
98
98
  return variable_token(start) if char == '$'
99
99
  return number_token(start) if char =~ /[0-9]/
@@ -103,12 +103,13 @@ module FHIRPath
103
103
  end
104
104
 
105
105
  def string_token(start)
106
+ quote = @source[@index]
106
107
  @index += 1
107
108
  value = +''
108
109
  until eof?
109
110
  char = @source[@index]
110
111
  @index += 1
111
- return token(:string, value, start) if char == "'"
112
+ return token(:string, value, start) if char == quote
112
113
 
113
114
  if char == '\\'
114
115
  raise_error('unterminated string escape', @index - 1) if eof?
@@ -118,14 +119,14 @@ module FHIRPath
118
119
  value << char
119
120
  end
120
121
  end
121
- raise_error('unterminated string literal', start)
122
+ raise_error('unterminated string literal', start, code: :unterminated_string)
122
123
  end
123
124
 
124
125
  def escaped_character
125
126
  escaped = @source[@index]
126
127
  @index += 1
127
128
  simple = { 'b' => "\b", 'f' => "\f", 'n' => "\n", 'r' => "\r",
128
- 't' => "\t", '\\' => '\\', "'" => "'", '/' => '/' }
129
+ 't' => "\t", '\\' => '\\', "'" => "'", '"' => '"', '/' => '/' }
129
130
  return simple.fetch(escaped) if simple.key?(escaped)
130
131
  return unicode_escape if escaped == 'u'
131
132
 
@@ -362,6 +363,8 @@ module FHIRPath
362
363
  end
363
364
 
364
365
  class Parser
366
+ OPERATOR_FUNCTIONS = %i[in contains].freeze
367
+
365
368
  PRECEDENCE = {
366
369
  implies: 1,
367
370
  or: 2, xor: 2,
@@ -438,7 +441,20 @@ module FHIRPath
438
441
  def parse_primary
439
442
  token = advance
440
443
  node = case token.type
441
- when :string, :integer, :decimal, :date, :time, :datetime
444
+ when :integer, :decimal
445
+ if current.type == :string
446
+ unit = advance
447
+ begin
448
+ value = Quantity.new(value: token.value, unit: unit.value)
449
+ rescue ArgumentError => e
450
+ fail_parse("invalid Quantity literal: #{e.message}", :invalid_quantity,
451
+ span_between(token.span, unit.span))
452
+ end
453
+ AST::Literal.new(value: value, span: span_between(token.span, unit.span))
454
+ else
455
+ AST::Literal.new(value: token.value, span: token.span)
456
+ end
457
+ when :string, :date, :time, :datetime
442
458
  AST::Literal.new(value: token.value, span: token.span)
443
459
  when :identifier
444
460
  if %w[true false].include?(token.value)
@@ -483,14 +499,7 @@ module FHIRPath
483
499
  loop do
484
500
  case current.type
485
501
  when :dot
486
- advance
487
- name = expect(:identifier)
488
- node = if current.type == :left_paren
489
- parse_function(node, name)
490
- else
491
- AST::MemberInvocation.new(receiver: node, name: name.value,
492
- span: span_between(node.span, name.span))
493
- end
502
+ node = parse_member_access(node)
494
503
  when :left_bracket
495
504
  advance
496
505
  index = parse_expression(0)
@@ -511,8 +520,34 @@ module FHIRPath
511
520
  node
512
521
  end
513
522
 
523
+ def parse_member_access(node)
524
+ advance
525
+ if current.type == :identifier
526
+ name = expect(:identifier)
527
+ # Regular identifier: can be either a member access or function call
528
+ if current.type == :left_paren
529
+ parse_function(node, name)
530
+ else
531
+ AST::MemberInvocation.new(receiver: node, name: name.value,
532
+ span: span_between(node.span, name.span))
533
+ end
534
+ elsif current.type == :operator && OPERATOR_FUNCTIONS.include?(current.value)
535
+ # Keyword operators (in, contains): ONLY allowed as function calls with ()
536
+ name = current
537
+ advance
538
+ if current.type == :left_paren
539
+ parse_function(node, name)
540
+ else
541
+ fail_parse('keyword operator requires function call syntax', :unexpected_token, current.span)
542
+ end
543
+ else
544
+ fail_parse('expected a function name', :unexpected_token, current.span)
545
+ end
546
+ end
547
+
514
548
  def parse_function(receiver, name_token)
515
549
  name = name_token.is_a?(AST::Identifier) ? name_token.name : name_token.value
550
+ name = name.to_s
516
551
  start = receiver ? receiver.span : name_token.span
517
552
  expect(:left_paren)
518
553
  arguments = []
@@ -70,4 +70,73 @@ module FHIRPath
70
70
  end
71
71
  end
72
72
  end
73
+
74
+ # A FHIRPath Quantity backed by an explicitly bounded UCUM subset.
75
+ class Quantity < Value::Base
76
+ DEFAULT_SYSTEM = 'http://unitsofmeasure.org'
77
+
78
+ attr_reader :unit, :system, :code
79
+
80
+ def initialize(value = nil, unit = nil, **kwargs)
81
+ value = kwargs.fetch(:value, value)
82
+ unit = kwargs.fetch(:unit, unit)
83
+ raise ArgumentError, 'Quantity value is required' if value.nil?
84
+
85
+ decimal = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
86
+ raise ArgumentError, 'Quantity value must be finite' unless decimal.finite?
87
+
88
+ @unit = UCUM.validate(unit)
89
+ @system = (kwargs[:system] || DEFAULT_SYSTEM).to_s.dup.freeze
90
+ @code = (kwargs[:code] || @unit).to_s.dup.freeze
91
+ super(decimal)
92
+ rescue ArgumentError
93
+ raise
94
+ rescue StandardError => e
95
+ raise ArgumentError, "invalid Quantity value: #{e.message}"
96
+ end
97
+
98
+ def to_ruby
99
+ to_h
100
+ end
101
+
102
+ def to_h
103
+ { value: value, unit: unit, system: system, code: code }.freeze
104
+ end
105
+
106
+ def compatible?(other)
107
+ other.is_a?(Quantity) && definition.dimensions == other.send(:definition).dimensions
108
+ end
109
+
110
+ def convert_to(target_unit)
111
+ target = UCUM.validate(target_unit)
112
+ return self if target == unit
113
+ raise ArgumentError, "incompatible Quantity units: #{unit} and #{target}" unless compatible_unit?(target)
114
+
115
+ converted = value * definition.factor / UCUM.definition(target).factor
116
+ Quantity.new(value: converted, unit: target, system: system, code: target)
117
+ end
118
+
119
+ def ==(other)
120
+ other.is_a?(Quantity) && compatible?(other) && value == other.convert_to(unit).value
121
+ end
122
+ alias eql? ==
123
+
124
+ def hash
125
+ [self.class, definition.dimensions, base_value].hash
126
+ end
127
+
128
+ private
129
+
130
+ def base_value
131
+ value * definition.factor
132
+ end
133
+
134
+ def definition
135
+ UCUM.definition(unit)
136
+ end
137
+
138
+ def compatible_unit?(target)
139
+ definition.dimensions == UCUM.definition(target).dimensions
140
+ end
141
+ end
73
142
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module FHIRPath
6
+ # Dependency-free, deliberately bounded UCUM support used by Quantity.
7
+ # Unknown atoms are rejected; callers should not interpret this subset as
8
+ # complete UCUM conformance.
9
+ module UCUM
10
+ UnitDefinition = Struct.new(:dimensions, :factor, keyword_init: true) do
11
+ def initialize(**kwargs)
12
+ super
13
+ self.dimensions = dimensions.freeze
14
+ freeze
15
+ end
16
+ end
17
+
18
+ MAX_EXPONENT = 12
19
+
20
+ UNIT_ATOMS = {
21
+ '1' => [{}, '1'],
22
+ 'm' => [{ length: 1 }, '1'],
23
+ 'cm' => [{ length: 1 }, '0.01'],
24
+ 'mm' => [{ length: 1 }, '0.001'],
25
+ 'km' => [{ length: 1 }, '1000'],
26
+ 'g' => [{ mass: 1 }, '1'],
27
+ 'kg' => [{ mass: 1 }, '1000'],
28
+ 'mg' => [{ mass: 1 }, '0.001'],
29
+ 'Mg' => [{ mass: 1 }, '1000000'],
30
+ 'ug' => [{ mass: 1 }, '0.000001'],
31
+ 'ng' => [{ mass: 1 }, '0.000000001'],
32
+ 'L' => [{ volume: 1 }, '1'],
33
+ 'mL' => [{ volume: 1 }, '0.001'],
34
+ 'ML' => [{ volume: 1 }, '1000000'],
35
+ 'uL' => [{ volume: 1 }, '0.000001'],
36
+ 'mol' => [{ amount: 1 }, '1'],
37
+ 'mmol' => [{ amount: 1 }, '0.001'],
38
+ 'umol' => [{ amount: 1 }, '0.000001'],
39
+ 's' => [{ time: 1 }, '1'],
40
+ 'min' => [{ time: 1 }, '60'],
41
+ 'h' => [{ time: 1 }, '3600']
42
+ }.freeze
43
+
44
+ module_function
45
+
46
+ def validate(unit)
47
+ text = unit.to_s.dup
48
+ definition(text)
49
+ text.freeze
50
+ end
51
+
52
+ def definition(unit)
53
+ raise ArgumentError, 'Quantity unit must not be blank' if unit.empty?
54
+ return UnitDefinition.new(dimensions: {}, factor: BigDecimal('1')) if unit == '1'
55
+
56
+ parts = unit.split('/', -1)
57
+ unless parts.length.between?(1, 2) && parts.none?(&:empty?)
58
+ raise ArgumentError, "unsupported Quantity unit: #{unit}"
59
+ end
60
+
61
+ numerator = product_definition(parts.first, unit)
62
+ denominator = build_denominator(parts, unit)
63
+ dimensions = combine_dimensions(numerator.dimensions, denominator.dimensions)
64
+ validate_dimensions!(dimensions)
65
+ UnitDefinition.new(dimensions: dimensions,
66
+ factor: numerator.factor * denominator.factor)
67
+ end
68
+
69
+ def build_denominator(parts, unit)
70
+ return unitless_definition unless parts.length == 2
71
+
72
+ product_definition(parts.last, unit, exponent_sign: -1)
73
+ end
74
+ private_class_method :build_denominator
75
+
76
+ def validate_dimensions!(dimensions)
77
+ dimensions.each_value do |power|
78
+ next unless power.abs > MAX_EXPONENT
79
+
80
+ raise ArgumentError, "Quantity unit exponent exceeds #{MAX_EXPONENT}"
81
+ end
82
+ end
83
+ private_class_method :validate_dimensions!
84
+
85
+ def product_definition(product, original, exponent_sign: 1)
86
+ dimensions = {}
87
+ factor = BigDecimal('1')
88
+ product.split('*', -1).each do |atom_with_exponent|
89
+ atom_dimensions, atom_factor, exponent = atom_definition(atom_with_exponent, original, exponent_sign)
90
+ atom_dimensions.each do |dimension, power|
91
+ dimensions[dimension] = dimensions.fetch(dimension, 0) + (power * exponent)
92
+ end
93
+ factor *= BigDecimal(atom_factor)**exponent
94
+ end
95
+ UnitDefinition.new(dimensions: dimensions.reject { |_dimension, power| power.zero? }, factor: factor)
96
+ end
97
+ private_class_method :product_definition
98
+
99
+ def atom_definition(atom_with_exponent, original, exponent_sign)
100
+ match = /\A([^\^]+)(?:\^(-?\d+))?\z/.match(atom_with_exponent)
101
+ raise ArgumentError, "unsupported Quantity unit: #{original}" unless match && UNIT_ATOMS.key?(match[1])
102
+
103
+ exponent = (match[2] || '1').to_i * exponent_sign
104
+ raise ArgumentError, "Quantity unit exponent exceeds #{MAX_EXPONENT}" if exponent.abs > MAX_EXPONENT
105
+
106
+ atom_dimensions, atom_factor = UNIT_ATOMS.fetch(match[1])
107
+ [atom_dimensions, atom_factor, exponent]
108
+ end
109
+ private_class_method :atom_definition
110
+
111
+ def unitless_definition
112
+ UnitDefinition.new(dimensions: {}, factor: BigDecimal('1'))
113
+ end
114
+ private_class_method :unitless_definition
115
+
116
+ def combine_dimensions(numerator, denominator)
117
+ numerator.merge(denominator) { |_dimension, left, right| left + right }
118
+ .reject { |_dimension, exponent| exponent.zero? }
119
+ end
120
+ private_class_method :combine_dimensions
121
+ end
122
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FHIRPath
4
- VERSION = '0.2.0.pre3'
4
+ VERSION = '0.2.0.pre4'
5
5
  # A stable release requires the complete release gate to be deliberately
6
6
  # promoted. Keep pre-release status explicit while the shared-suite and
7
7
  # model-adapter work remains incomplete.
data/lib/fhirpath.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'fhirpath/source_span'
5
5
  require_relative 'fhirpath/errors'
6
6
  require_relative 'fhirpath/capability'
7
7
  require_relative 'fhirpath/collection'
8
+ require_relative 'fhirpath/ucum'
8
9
  require_relative 'fhirpath/types'
9
10
  require_relative 'fhirpath/ast'
10
11
  require_relative 'fhirpath/model'
@@ -46,8 +47,13 @@ module FHIRPath
46
47
  def evaluate(resource, expression, variables: {}, model: nil,
47
48
  capability: Capability.current, functions: FunctionRegistry.standard,
48
49
  options: {}, host: nil)
49
- compile(expression, model: model, capability: capability, functions: functions)
50
- .evaluate(resource, variables: variables, host: host, options: options)
50
+ compiled = if expression.is_a?(CompiledExpression)
51
+ expression
52
+ else
53
+ compile(expression, model: model, capability: capability,
54
+ functions: functions)
55
+ end
56
+ compiled.evaluate(resource, variables: variables, host: host, options: options)
51
57
  end
52
58
 
53
59
  def evaluate_first(resource, expression, variables: {}, model: nil,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fhirpath
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.pre3
4
+ version: 0.2.0.pre4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicco Reyes
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -81,6 +81,7 @@ files:
81
81
  - lib/fhirpath/parser.rb
82
82
  - lib/fhirpath/source_span.rb
83
83
  - lib/fhirpath/types.rb
84
+ - lib/fhirpath/ucum.rb
84
85
  - lib/fhirpath/vector_runner.rb
85
86
  - lib/fhirpath/version.rb
86
87
  - script/import_vectors.rb
@@ -91,7 +92,7 @@ licenses:
91
92
  metadata:
92
93
  release_status: pre-release
93
94
  fhirpath_target: 2.0.0
94
- capability_set: parser,immutable-ast,collection-evaluation,plain-model-navigation,primitive-values,arithmetic,comparison-and-equivalence,boolean-logic,union-membership-and-type-operators,collection-functions,focus-variables,external-constants,custom-functions,compiled-expression-reuse,fhir-r4-model,structured-errors
95
+ capability_set: parser,immutable-ast,collection-evaluation,plain-model-navigation,primitive-values,arithmetic,comparison-and-equivalence,boolean-logic,union-membership-and-type-operators,collection-functions,focus-variables,external-constants,custom-functions,compiled-expression-reuse,fhir-r4-model,structured-errors,quantity-ucum,aggregate
95
96
  source_code_uri: https://github.com/niccoreyes/fhirpath-ruby
96
97
  bug_tracker_uri: https://github.com/niccoreyes/fhirpath-ruby/issues
97
98
  documentation_uri: https://github.com/niccoreyes/fhirpath-ruby/blob/main/docs/api.md