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 +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +28 -0
- data/README.md +37 -21
- data/conformance/official-r4-core.json +3 -8
- data/docs/api.md +3 -0
- data/docs/conformance.md +19 -1
- data/docs/feature-matrix.md +13 -10
- data/docs/support-matrix.md +57 -4
- data/lib/fhirpath/capability.rb +2 -2
- data/lib/fhirpath/compiled_expression.rb +39 -0
- data/lib/fhirpath/conformance/importer.rb +11 -8
- data/lib/fhirpath/errors.rb +8 -0
- data/lib/fhirpath/evaluator.rb +147 -2
- data/lib/fhirpath/functions.rb +17 -5
- data/lib/fhirpath/models_r4.rb +58 -0
- data/lib/fhirpath/parser.rb +48 -13
- data/lib/fhirpath/types.rb +69 -0
- data/lib/fhirpath/ucum.rb +122 -0
- data/lib/fhirpath/version.rb +1 -1
- data/lib/fhirpath.rb +8 -2
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eacb9dc2934ca76d58acb3c6aab97acc1f2d602f436d28d1518a0ee26a25c14d
|
|
4
|
+
data.tar.gz: 7460ad33f47fd12408aaa905318285cec5e05f4db3503dcfd3ea4612c2d3ab53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e5de5efdbd7037022c0e36587dbebec79ab0406da23b76067b858d49f47b9edfecff975ef9e048fb19fd3455c1e2e704ad4491f77bffb47517abc46e3d5fc64
|
|
7
|
+
data.tar.gz: 5e7b7ec8152d3534464c70b739e025d63b06490f3c3fe8dcb16dc26017af0597d888243057de6600ac5a922eddda05035a7a5254773085a6d738ef339b65d494
|
data/.rubocop.yml
CHANGED
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
|
[](https://github.com/niccoreyes/fhirpath-ruby/actions/workflows/release.yml)
|
|
5
5
|
[](https://github.com/niccoreyes/fhirpath-ruby/blob/main/LICENSE)
|
|
6
6
|
[](https://github.com/niccoreyes/fhirpath-ruby/blob/main/.github/workflows/ci.yml)
|
|
7
|
+
[](https://rubygems.org/gems/fhirpath)
|
|
8
|
+
[](https://rubygems.org/gems/fhirpath)
|
|
9
|
+
[](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.
|
|
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;
|
|
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
|
|
28
|
+
The gem is published on [RubyGems.org](https://rubygems.org/gems/fhirpath):
|
|
26
29
|
|
|
27
30
|
```sh
|
|
28
|
-
|
|
29
|
-
cd fhirpath-ruby
|
|
30
|
-
bundle install
|
|
31
|
-
bundle exec rake test
|
|
31
|
+
gem install fhirpath
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
# =>
|
|
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
|
-
-
|
|
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": "
|
|
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
|
-
"
|
|
10
|
-
|
|
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
|
|
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:
|
data/docs/feature-matrix.md
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
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 |
|
|
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 |
|
|
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 |
|
data/docs/support-matrix.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
79
|
-
-
|
|
80
|
-
- advanced conversion, math, string, regular-expression, and navigation functions
|
|
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
|
data/lib/fhirpath/capability.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
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['
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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|
|
data/lib/fhirpath/errors.rb
CHANGED
|
@@ -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)
|
data/lib/fhirpath/evaluator.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
data/lib/fhirpath/functions.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
data/lib/fhirpath/models_r4.rb
CHANGED
|
@@ -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
|
|
data/lib/fhirpath/parser.rb
CHANGED
|
@@ -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
|
|
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 :
|
|
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
|
-
|
|
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 = []
|
data/lib/fhirpath/types.rb
CHANGED
|
@@ -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
|
data/lib/fhirpath/version.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FHIRPath
|
|
4
|
-
VERSION = '0.2.0.
|
|
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
|
-
|
|
50
|
-
|
|
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.
|
|
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-
|
|
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
|