fhirpath-rb 0.1.1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/CLAUDE.md +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +12 -0
- data/ext/fhir_path_parser/extconf.rb +40 -0
- data/lib/fhirpath/engine/evaluators/expressions.rb +59 -0
- data/lib/fhirpath/engine/evaluators/literals.rb +70 -0
- data/lib/fhirpath/engine/evaluators/member_invocation.rb +109 -0
- data/lib/fhirpath/engine/evaluators.rb +130 -0
- data/lib/fhirpath/engine/invocations/aggregate.rb +48 -0
- data/lib/fhirpath/engine/invocations/collections.rb +32 -0
- data/lib/fhirpath/engine/invocations/combining.rb +30 -0
- data/lib/fhirpath/engine/invocations/datetime.rb +34 -0
- data/lib/fhirpath/engine/invocations/equality.rb +95 -0
- data/lib/fhirpath/engine/invocations/equivalence.rb +138 -0
- data/lib/fhirpath/engine/invocations/existence.rb +117 -0
- data/lib/fhirpath/engine/invocations/filtering.rb +107 -0
- data/lib/fhirpath/engine/invocations/inequality.rb +92 -0
- data/lib/fhirpath/engine/invocations/logic.rb +74 -0
- data/lib/fhirpath/engine/invocations/math.rb +109 -0
- data/lib/fhirpath/engine/invocations/math_functions.rb +121 -0
- data/lib/fhirpath/engine/invocations/misc.rb +144 -0
- data/lib/fhirpath/engine/invocations/misc_converts_to.rb +91 -0
- data/lib/fhirpath/engine/invocations/navigation.rb +102 -0
- data/lib/fhirpath/engine/invocations/registry_strings_and_math.rb +48 -0
- data/lib/fhirpath/engine/invocations/strings.rb +139 -0
- data/lib/fhirpath/engine/invocations/strings_encoding.rb +68 -0
- data/lib/fhirpath/engine/invocations/subsetting.rb +15 -0
- data/lib/fhirpath/engine/invocations/types.rb +36 -0
- data/lib/fhirpath/engine/invocations.rb +118 -0
- data/lib/fhirpath/engine/nodes/fp_date_time.rb +140 -0
- data/lib/fhirpath/engine/nodes/fp_date_time_arithmetic.rb +112 -0
- data/lib/fhirpath/engine/nodes/fp_quantity.rb +142 -0
- data/lib/fhirpath/engine/nodes/fp_quantity_deep_equal.rb +67 -0
- data/lib/fhirpath/engine/nodes/fp_time.rb +139 -0
- data/lib/fhirpath/engine/nodes/fp_time_arithmetic.rb +111 -0
- data/lib/fhirpath/engine/nodes/resource_node.rb +66 -0
- data/lib/fhirpath/engine/nodes/type_info.rb +92 -0
- data/lib/fhirpath/engine/param_resolution.rb +74 -0
- data/lib/fhirpath/engine/util.rb +117 -0
- data/lib/fhirpath/engine.rb +100 -0
- data/lib/fhirpath/models/dstu2/choiceTypePaths.json +1041 -0
- data/lib/fhirpath/models/dstu2/path2Type.json +4728 -0
- data/lib/fhirpath/models/dstu2/pathsDefinedElsewhere.json +21 -0
- data/lib/fhirpath/models/dstu2/type2Parent.json +141 -0
- data/lib/fhirpath/models/r4/choiceTypePaths.json +1365 -0
- data/lib/fhirpath/models/r4/path2Type.json +7707 -0
- data/lib/fhirpath/models/r4/pathsDefinedElsewhere.json +57 -0
- data/lib/fhirpath/models/r4/type2Parent.json +211 -0
- data/lib/fhirpath/models/r5/choiceTypePaths.json +1883 -0
- data/lib/fhirpath/models/r5/path2Type.json +9653 -0
- data/lib/fhirpath/models/r5/pathsDefinedElsewhere.json +80 -0
- data/lib/fhirpath/models/r5/type2Parent.json +234 -0
- data/lib/fhirpath/models/stu3/choiceTypePaths.json +991 -0
- data/lib/fhirpath/models/stu3/path2Type.json +5729 -0
- data/lib/fhirpath/models/stu3/pathsDefinedElsewhere.json +37 -0
- data/lib/fhirpath/models/stu3/type2Parent.json +173 -0
- data/lib/fhirpath/models.rb +25 -0
- data/lib/fhirpath/parser/FHIRPath.g4 +172 -0
- data/lib/fhirpath/parser/ast_builder.rb +51 -0
- data/lib/fhirpath/parser.rb +21 -0
- data/lib/fhirpath/version.rb +5 -0
- data/lib/fhirpath.rb +117 -0
- data/rakelib/parser.rake +146 -0
- data/sig/fhirpath.rbs +14 -0
- metadata +168 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9eefe06c5824eee637149d60af147480e36b717e5c6f15d3ad498529b9a2c52c
|
|
4
|
+
data.tar.gz: 1a2692a532e82590b309277b0f8a75ea7dff0d95565bda303dcb386303634274
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 70c7b85868c2ec1cc185674a314d5923fcaeb31d950c4143fb4f439746aa7b9862d28e6f49914689d421af1c6888bf1ee0f8899cea6ced726b8113db89d23410
|
|
7
|
+
data.tar.gz: 05cf47e784e7560b02a77914cd1a64a9801296898ad94a46281a5960ca1b8574cddd2dd70d7b576e4a983059d13b0c684d786eb475f41cac1152d51987808296
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.0
|
|
3
|
+
|
|
4
|
+
Style/StringLiterals:
|
|
5
|
+
EnforcedStyle: double_quotes
|
|
6
|
+
|
|
7
|
+
Style/StringLiteralsInInterpolation:
|
|
8
|
+
EnforcedStyle: double_quotes
|
|
9
|
+
|
|
10
|
+
# extconf.rb's $CFLAGS/$srcs/etc are mkmf's own API, not avoidable globals.
|
|
11
|
+
Style/GlobalVars:
|
|
12
|
+
Exclude:
|
|
13
|
+
- "ext/**/extconf.rb"
|
|
14
|
+
|
|
15
|
+
Style/FrozenStringLiteralComment:
|
|
16
|
+
Exclude:
|
|
17
|
+
- "ext/**/extconf.rb"
|
|
18
|
+
|
|
19
|
+
# Both files are inherently long: one assembles generated C++ source via heredocs, the other
|
|
20
|
+
# lists fhirpath-py's ported test expressions.
|
|
21
|
+
Metrics/BlockLength:
|
|
22
|
+
Exclude:
|
|
23
|
+
- "rakelib/**/*.rake"
|
|
24
|
+
- "spec/**/*_spec.rb"
|
|
25
|
+
- "*.gemspec"
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# fhirpath-rb
|
|
2
|
+
|
|
3
|
+
Ruby implementation of [FHIRPath](https://hl7.org/fhirpath/), the expression language used to
|
|
4
|
+
navigate and extract data from FHIR resources. This is a **port of
|
|
5
|
+
[fhirpath-py](https://github.com/beda-software/fhirpath-py)** — when in doubt about behavior,
|
|
6
|
+
naming, or edge cases, treat fhirpath-py as the reference implementation rather than
|
|
7
|
+
re-deriving from the spec alone. fhirpath-py is itself a port of
|
|
8
|
+
[fhirpath.js](https://github.com/HL7/fhirpath.js) (the canonical HL7 reference implementation),
|
|
9
|
+
so fhirpath.js is the next place to check if fhirpath-py is ambiguous.
|
|
10
|
+
|
|
11
|
+
## Status
|
|
12
|
+
|
|
13
|
+
Repo scaffolding only — no evaluator code has been ported yet. `lib/fhirpath.rb` just defines
|
|
14
|
+
the `Fhirpath` module and `Fhirpath::Error`. Everything in "Planned architecture" below
|
|
15
|
+
describes the target design, not current code.
|
|
16
|
+
|
|
17
|
+
## Planned architecture
|
|
18
|
+
|
|
19
|
+
Mirror fhirpath-py's module boundaries; they map cleanly to a Ruby gem's `lib/` layout:
|
|
20
|
+
|
|
21
|
+
| fhirpath-py | Planned Ruby equivalent | Purpose |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `fhirpathpy/__init__.py` (`evaluate`, `compile`) | `lib/fhirpath.rb` (`Fhirpath.evaluate`, `Fhirpath.compile`) | Public API |
|
|
24
|
+
| `fhirpathpy/parser/FHIRPath.g4` + antlr-generated parser | `lib/fhirpath/parser/` | Grammar + parser producing an AST. No Ruby ANTLR runtime is planned by default — evaluate whether to generate a parser from the same `.g4` grammar (via a build step, not committed generated code) or hand-write a recursive-descent/Parslet/Racc parser that accepts the same grammar. |
|
|
25
|
+
| `fhirpathpy/engine/nodes.py` (`FP_Type`, `FP_Quantity`, `FP_DateTime`, `ResourceNode`, ...) | `lib/fhirpath/engine/nodes.rb` (or one file per type under `lib/fhirpath/engine/nodes/`) | FHIRPath value types (quantities, date/time with partial precision, resource wrapper) and their `equals`/`equivalent_to`/`compare` semantics |
|
|
26
|
+
| `fhirpathpy/engine/__init__.py` (`do_eval`) + `evaluators/` | `lib/fhirpath/engine/evaluator.rb` | AST node dispatch — walks the parsed tree and evaluates each node kind against the current context |
|
|
27
|
+
| `fhirpathpy/engine/invocations/*.py` (aggregate, collections, combining, constants, datetime, equality, existence, filtering, logic, math, misc, navigation, strings, subsetting, types) | `lib/fhirpath/engine/invocations/*.rb`, one file per category | Built-in function library (`where`, `select`, `first`, `substring`, `today()`, ...); each category stays its own file to match fhirpath-py |
|
|
28
|
+
| `fhirpathpy/engine/util.py` (`get_data`, `parse_value`, `set_paths`, `arraify`, `process_user_invocation_table`) | `lib/fhirpath/engine/util.rb` | Shared helpers used across the engine |
|
|
29
|
+
| `fhirpathpy/models/{dstu2,stu3,r4,r5}` | `lib/fhirpath/models/{dstu2,stu3,r4,r5}.rb` | Per-FHIR-version model data (choice-type expansion, primitive extension handling) used to disambiguate polymorphic fields like `Patient.deceased[x]` |
|
|
30
|
+
|
|
31
|
+
Public API surface to match fhirpath-py's `evaluate`/`compile` (see its README):
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
Fhirpath.evaluate(resource, path, context = {}, model = nil, options = {})
|
|
35
|
+
Fhirpath.compile(path, model = nil, options = {}) # => callable, parses once, reusable across resources
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`options[:user_invocation_table]` should let callers override or add functions, mirroring
|
|
39
|
+
fhirpath-py's `userInvocationTable` (see its README's "User-defined functions" section).
|
|
40
|
+
|
|
41
|
+
## Test strategy
|
|
42
|
+
|
|
43
|
+
fhirpath-py's `tests/cases/*.yaml` are FHIRPath-spec-section-organized YAML test cases (e.g.
|
|
44
|
+
`5.1_existence.yaml`, `6.5_boolean_logic.yaml`) plus FHIR-specific cases (`fhir-r4.yaml`,
|
|
45
|
+
`fhir-quantity.yaml`). When porting the evaluator, port these fixtures (or write a shared
|
|
46
|
+
YAML-driven RSpec runner that reads an equivalent fixture format) rather than inventing a
|
|
47
|
+
parallel set of hand-written examples — they encode a lot of spec edge cases that are easy to
|
|
48
|
+
miss otherwise.
|
|
49
|
+
|
|
50
|
+
## Conventions
|
|
51
|
+
|
|
52
|
+
- Ruby >= 3.0, `frozen_string_literal: true` in every file.
|
|
53
|
+
- Gem name is `fhirpath-rb` (matching the `fhirpath-py` naming pattern) but the module is flat
|
|
54
|
+
`Fhirpath`, not `Fhirpath::Rb` — `rb`/`py` are naming suffixes on the repo/gem, not part of
|
|
55
|
+
the API, so callers write `Fhirpath.evaluate(...)`, not `Fhirpath::Rb.evaluate(...)`.
|
|
56
|
+
- RSpec for tests (`spec/`), RuboCop for linting (`.rubocop.yml`). `bundle exec rake` runs both
|
|
57
|
+
(the default Rake task) and must stay clean.
|
|
58
|
+
- No implementation code should be added speculatively — this file describes the target shape
|
|
59
|
+
so future work has a map, not a request to build it all now.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beda.software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# fhirpath-rb
|
|
2
|
+
|
|
3
|
+
[FHIRPath](https://hl7.org/fhirpath/) implementation in Ruby.
|
|
4
|
+
|
|
5
|
+
This is a Ruby port of [fhirpath-py](https://github.com/beda-software/fhirpath-py), which is
|
|
6
|
+
itself a Python port of [fhirpath.js](https://github.com/HL7/fhirpath.js). The goal is to bring
|
|
7
|
+
the same evaluator (parser, engine, built-in function library, and FHIR data models) to the
|
|
8
|
+
Ruby ecosystem.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Not yet released to RubyGems. Once published:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle add fhirpath-rb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Planned usage
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "fhirpath"
|
|
22
|
+
|
|
23
|
+
patient = {
|
|
24
|
+
"resourceType" => "Patient",
|
|
25
|
+
"name" => [
|
|
26
|
+
{ "use" => "official", "given" => ["Peter", "James"], "family" => "Chalmers" },
|
|
27
|
+
{ "use" => "usual", "given" => ["Jim"] }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Fhirpath.evaluate(patient, "Patient.name.where(use = 'usual').given.first()")
|
|
32
|
+
# => ["Jim"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run `rake spec` to
|
|
38
|
+
run the tests, and `bundle exec rake` to run the full default task (specs + rubocop). You can
|
|
39
|
+
also run `bin/console` for an interactive prompt.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "mkmf-rice"
|
|
2
|
+
|
|
3
|
+
extension_name = "fhir_path_parser"
|
|
4
|
+
dir_config(extension_name)
|
|
5
|
+
|
|
6
|
+
have_library("stdc++")
|
|
7
|
+
|
|
8
|
+
$CFLAGS << " -std=c++14"
|
|
9
|
+
|
|
10
|
+
# The antlr4 runtime sources below are compiled directly into this extension, never built as
|
|
11
|
+
# a separate DLL, so ANTLR4CPP_PUBLIC must never expand to __declspec(dllimport) (its default
|
|
12
|
+
# on Windows) — that would reject definitions like static data members as belonging to an
|
|
13
|
+
# imported class. Define it unconditionally rather than gating on --enable-static.
|
|
14
|
+
$defs.push("-DANTLR4CPP_STATIC") unless $defs.include?("-DANTLR4CPP_STATIC")
|
|
15
|
+
|
|
16
|
+
include_paths = [
|
|
17
|
+
".",
|
|
18
|
+
"antlrgen",
|
|
19
|
+
"antlr4-upstream/runtime/Cpp/runtime/src",
|
|
20
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/atn",
|
|
21
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/dfa",
|
|
22
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/misc",
|
|
23
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/support",
|
|
24
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/tree",
|
|
25
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/tree/pattern",
|
|
26
|
+
"antlr4-upstream/runtime/Cpp/runtime/src/tree/xpath"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
$srcs = []
|
|
30
|
+
|
|
31
|
+
include_paths.each do |include_path|
|
|
32
|
+
$INCFLAGS << " -I#{include_path}"
|
|
33
|
+
$VPATH << include_path
|
|
34
|
+
|
|
35
|
+
Dir.glob("#{include_path}/*.cpp").each do |path|
|
|
36
|
+
$srcs << path
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
create_makefile(extension_name)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
# Unary/prefix and infix-operator expression evaluators; see evaluators.rb for
|
|
6
|
+
# member/index/function invocation, chaining, and the node-type dispatch table.
|
|
7
|
+
module Evaluators
|
|
8
|
+
class << self
|
|
9
|
+
def polarity_expression(ctx, parent_data, node)
|
|
10
|
+
sign = node["terminalNodeText"].first
|
|
11
|
+
result = Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
12
|
+
raise Fhirpath::Error, "Unary #{sign} can only be applied to an individual number." if result.length != 1
|
|
13
|
+
|
|
14
|
+
value = Util.get_data(result.first)
|
|
15
|
+
raise Fhirpath::Error, "Unary #{sign} can only be applied to a number." unless value.is_a?(::Numeric)
|
|
16
|
+
|
|
17
|
+
[sign == "-" ? -value : value]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Ports fhirpath-py's op_expression: reads the infix operator's symbol off the node and
|
|
21
|
+
# dispatches to the matching invocation (e.g. EqualityExpression's "=").
|
|
22
|
+
def op_expression(ctx, parent_data, node)
|
|
23
|
+
op = node["terminalNodeText"].first
|
|
24
|
+
Engine.infix_invoke(ctx, op, parent_data, node["children"])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Unlike op_expression, "|" is fixed rather than read off the node.
|
|
28
|
+
def union_expression(ctx, parent_data, node)
|
|
29
|
+
Engine.infix_invoke(ctx, "|", parent_data, node["children"])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Ports fhirpath-py's alias_op_expression({"is": "isOp", "as": "asOp"}): "is"/"as" used
|
|
33
|
+
# as infix operators (e.g. "x is Boolean") dispatch to the "...Op" invocation names.
|
|
34
|
+
TYPE_EXPRESSION_ALIASES = { "is" => "isOp", "as" => "asOp" }.freeze
|
|
35
|
+
|
|
36
|
+
def type_expression(ctx, parent_data, node)
|
|
37
|
+
aliased_op_expression(ctx, parent_data, node, TYPE_EXPRESSION_ALIASES)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Ports fhirpath-py's alias_op_expression({"contains": "containsOp", "in": "inOp"}).
|
|
41
|
+
MEMBERSHIP_EXPRESSION_ALIASES = { "contains" => "containsOp", "in" => "inOp" }.freeze
|
|
42
|
+
|
|
43
|
+
def membership_expression(ctx, parent_data, node)
|
|
44
|
+
aliased_op_expression(ctx, parent_data, node, MEMBERSHIP_EXPRESSION_ALIASES)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def aliased_op_expression(ctx, parent_data, node, aliases)
|
|
50
|
+
op = node["terminalNodeText"].first
|
|
51
|
+
alias_name = aliases[op]
|
|
52
|
+
raise Fhirpath::Error, "Do not know how to alias #{op} by #{aliases}" unless alias_name
|
|
53
|
+
|
|
54
|
+
Engine.infix_invoke(ctx, alias_name, parent_data, node["children"])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
# Literal-term evaluators (number/string/boolean/null/quantity/date-time), ported from
|
|
8
|
+
# fhirpath-py's fhirpathpy/engine/evaluators/__init__.py. Split out of evaluators.rb to keep
|
|
9
|
+
# that file's module body short; reopens the same Evaluators module.
|
|
10
|
+
module Evaluators
|
|
11
|
+
class << self
|
|
12
|
+
def number_literal(_ctx, _parent_data, node)
|
|
13
|
+
value = BigDecimal(node["text"])
|
|
14
|
+
int_value = value.to_i
|
|
15
|
+
[value == int_value ? int_value : value]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def string_literal(_ctx, _parent_data, node)
|
|
19
|
+
text = node["text"].sub(/\A['"]/, "").sub(/['"]\z/, "")
|
|
20
|
+
text = text.gsub("\\'", "'")
|
|
21
|
+
.gsub("\\`", "`")
|
|
22
|
+
.gsub('\\"', '"')
|
|
23
|
+
.gsub("\\r", "\r")
|
|
24
|
+
.gsub("\\n", "\n")
|
|
25
|
+
.gsub("\\t", "\t")
|
|
26
|
+
.gsub("\\f", "\f")
|
|
27
|
+
.gsub("\\\\", "\\")
|
|
28
|
+
[text.gsub(/\\u(\h{4})/) { [::Regexp.last_match(1).to_i(16)].pack("U") }]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def boolean_literal(_ctx, _parent_data, node)
|
|
32
|
+
[node["text"] == "true"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def null_literal(_ctx, _parent_data, _node)
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def quantity_literal(_ctx, _parent_data, node)
|
|
40
|
+
value_node = node["children"][0]
|
|
41
|
+
value = BigDecimal(value_node["terminalNodeText"][0])
|
|
42
|
+
unit_node = value_node["children"][0]
|
|
43
|
+
unit = quantity_unit(unit_node)
|
|
44
|
+
|
|
45
|
+
[Nodes::FPQuantity.new(value, unit)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def date_time_literal(_ctx, _parent_data, node)
|
|
49
|
+
[Nodes::FPDateTime.new(node["text"][1..])]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def time_literal(_ctx, _parent_data, node)
|
|
53
|
+
[Nodes::FPTime.new(node["text"][2..])]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# A Unit node's terminalNodeText holds a quoted UCUM unit (e.g. "'mo'") directly; for a
|
|
59
|
+
# calendar duration unit (e.g. "years") it instead has a PluralDateTimePrecision child
|
|
60
|
+
# whose terminalNodeText holds the word.
|
|
61
|
+
def quantity_unit(unit_node)
|
|
62
|
+
return unit_node["terminalNodeText"].first if unit_node["terminalNodeText"]&.any?
|
|
63
|
+
return unit_node["children"].first["terminalNodeText"].first if unit_node["children"]&.any?
|
|
64
|
+
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
# MemberInvocation evaluator (e.g. the ".value" in "Observation.value") and its reducer,
|
|
6
|
+
# ported from fhirpath-py's member_invocation/create_reduce_member_invocation
|
|
7
|
+
# (fhirpathpy/engine/evaluators/__init__.py). Split out of evaluators.rb to keep that file's
|
|
8
|
+
# module body short; reopens the same Evaluators module.
|
|
9
|
+
module Evaluators
|
|
10
|
+
class << self
|
|
11
|
+
def member_invocation(ctx, parent_data, node)
|
|
12
|
+
key = Engine.do_eval(ctx, parent_data, node["children"][0])[0].delete("`")
|
|
13
|
+
return [] unless parent_data.is_a?(::Array)
|
|
14
|
+
|
|
15
|
+
matches = resource_type_matches(parent_data, key)
|
|
16
|
+
return matches if matches
|
|
17
|
+
|
|
18
|
+
model = ctx[:model]
|
|
19
|
+
parent_data.reduce([]) { |acc, res| reduce_member(acc, res, model, key) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# The special case where a capitalized path segment (e.g. the leading "Observation" in
|
|
25
|
+
# "Observation.value") filters a collection of raw resource hashes by resourceType,
|
|
26
|
+
# rather than looking up an ordinary property.
|
|
27
|
+
def resource_type_matches(parent_data, key)
|
|
28
|
+
return nil unless Util.capitalized?(key)
|
|
29
|
+
|
|
30
|
+
filtered = parent_data.select { |item| item["resourceType"] == key }
|
|
31
|
+
filtered.map { |item| Nodes::ResourceNode.create_node(item, key) }
|
|
32
|
+
rescue ::NoMethodError, ::TypeError
|
|
33
|
+
# parent_data holds nodes that don't support `[]` (e.g. already ResourceNodes) — fall
|
|
34
|
+
# through to the ordinary member lookup below, matching fhirpath-py's TypeError catch.
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reduce_member(acc, res, model, key)
|
|
39
|
+
res = Nodes::ResourceNode.create_node(res)
|
|
40
|
+
child_path, full_path, actual_types = member_lookup_paths(res, key, model)
|
|
41
|
+
to_add, to_add_ext, child_path = member_lookup_value(res, key, actual_types, child_path)
|
|
42
|
+
child_path = remap(model, "path2Type", child_path)
|
|
43
|
+
|
|
44
|
+
acc = append_member_value(acc, to_add, child_path, full_path)
|
|
45
|
+
append_member_value(acc, to_add_ext, child_path, full_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def member_lookup_paths(res, key, model)
|
|
49
|
+
child_path = res.path ? "#{res.path}.#{key}" : "_.#{key}"
|
|
50
|
+
full_path = (res.prop_name ? "#{res.prop_name}.#{key}" : child_path).delete("_")
|
|
51
|
+
child_path = remap(model, "pathsDefinedElsewhere", child_path)
|
|
52
|
+
actual_types = model.is_a?(::Hash) ? model.dig("choiceTypePaths", child_path) : nil
|
|
53
|
+
|
|
54
|
+
[child_path, full_path, actual_types]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def member_lookup_value(res, key, actual_types, child_path)
|
|
58
|
+
return [res.data.value, nil, child_path] if res.data.is_a?(Nodes::FPQuantity)
|
|
59
|
+
return choice_type_value(res, key, actual_types, child_path) if choice_type?(res, actual_types)
|
|
60
|
+
return hash_value(res, key, child_path) if res.data.is_a?(::Hash)
|
|
61
|
+
return [res.data.length, nil, child_path] if key == "length" && res.data.respond_to?(:length)
|
|
62
|
+
|
|
63
|
+
[nil, nil, child_path]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def choice_type?(res, actual_types)
|
|
67
|
+
actual_types && !actual_types.empty? && res.data.is_a?(::Hash)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def hash_value(res, key, child_path)
|
|
71
|
+
child_path = "Extension" if key == "extension"
|
|
72
|
+
[res.data[key], res.data["_#{key}"], child_path]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def choice_type_value(res, key, actual_types, child_path)
|
|
76
|
+
actual_types.each do |actual_type|
|
|
77
|
+
field = "#{key}#{actual_type}"
|
|
78
|
+
to_add = res.data[field]
|
|
79
|
+
to_add_ext = res.data["_#{field}"]
|
|
80
|
+
next if to_add.nil? && to_add_ext.nil?
|
|
81
|
+
|
|
82
|
+
return [to_add, to_add_ext, child_path + actual_type]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[nil, nil, child_path]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def remap(model, table, child_path)
|
|
89
|
+
return child_path unless model.is_a?(::Hash) && model[table]
|
|
90
|
+
|
|
91
|
+
model[table].fetch(child_path, child_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def append_member_value(acc, value, child_path, full_path)
|
|
95
|
+
return acc unless Util.some?(value)
|
|
96
|
+
|
|
97
|
+
if value.is_a?(::Array)
|
|
98
|
+
mapped = value.each_with_index.map do |item, i|
|
|
99
|
+
Nodes::ResourceNode.create_node(item, child_path, prop_name: "#{full_path}[#{i}]", index: i)
|
|
100
|
+
end
|
|
101
|
+
acc + mapped
|
|
102
|
+
else
|
|
103
|
+
acc + [Nodes::ResourceNode.create_node(value, child_path, prop_name: full_path)]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "evaluators/expressions"
|
|
4
|
+
require_relative "evaluators/member_invocation"
|
|
5
|
+
require_relative "evaluators/literals"
|
|
6
|
+
|
|
7
|
+
module Fhirpath
|
|
8
|
+
module Engine
|
|
9
|
+
# Ruby port of fhirpath-py's fhirpathpy/engine/evaluators/__init__.py: dispatch table from
|
|
10
|
+
# AST node "type" to the function that evaluates it, plus the term/identifier/invocation
|
|
11
|
+
# evaluators. Unary/binary *Expression evaluators live in evaluators/expressions.rb, literal
|
|
12
|
+
# evaluators live in evaluators/literals.rb; this file is reopened by both.
|
|
13
|
+
module Evaluators
|
|
14
|
+
class << self
|
|
15
|
+
def identifier(_ctx, _parent_data, node)
|
|
16
|
+
[node["text"].sub(/\A"/, "").sub(/"\z/, "")]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def this_invocation(ctx, _parent_data, _node)
|
|
20
|
+
Util.arraify(ctx[:this])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def index_invocation(ctx, _parent_data, _node)
|
|
24
|
+
Util.arraify(ctx[:index])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def total_invocation(ctx, _parent_data, _node)
|
|
28
|
+
Util.arraify(ctx[:total])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ports fhirpath-py's external_constant_term ("%name"/"%`escaped name`" environment
|
|
32
|
+
# variables, https://hl7.org/fhirpath/#environment-variables).
|
|
33
|
+
def external_constant_term(ctx, _parent_data, node)
|
|
34
|
+
var_name = node["children"][0]["children"][0]["text"].delete("`")
|
|
35
|
+
unless ctx[:vars].key?(var_name)
|
|
36
|
+
raise Fhirpath::Error, "Attempting to access an undefined environment variable: #{var_name}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Util.arraify(ctx[:vars][var_name])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def literal_term(ctx, parent_data, node)
|
|
43
|
+
term = node["children"]&.first
|
|
44
|
+
term ? Engine.do_eval(ctx, parent_data, term) : [node["text"]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parenthesized_term(ctx, parent_data, node)
|
|
48
|
+
Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def invocation_term(ctx, parent_data, node)
|
|
52
|
+
Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def term_expression(ctx, parent_data, node)
|
|
56
|
+
Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invocation_expression(ctx, parent_data, node)
|
|
60
|
+
node["children"].reduce(parent_data) { |acc, child| Engine.do_eval(ctx, acc, child) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def indexer_expression(ctx, parent_data, node)
|
|
64
|
+
coll = Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
65
|
+
idx = Engine.do_eval(ctx, parent_data, node["children"][1])
|
|
66
|
+
return [] if Util.empty?(idx)
|
|
67
|
+
|
|
68
|
+
idx_num = idx[0].to_i
|
|
69
|
+
coll && idx_num >= 0 && coll.length > idx_num ? [coll[idx_num]] : []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def functn(ctx, parent_data, node)
|
|
73
|
+
node["children"].map { |child| Engine.do_eval(ctx, parent_data, child) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def param_list(_ctx, _parent_data, node)
|
|
77
|
+
node
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def function_invocation(ctx, parent_data, node)
|
|
81
|
+
args = Engine.do_eval(ctx, parent_data, node["children"][0])
|
|
82
|
+
fn_name = args[0]
|
|
83
|
+
args = args[1..] || []
|
|
84
|
+
|
|
85
|
+
raw_params = nil
|
|
86
|
+
raw_params = args[0]["children"] if args[0].is_a?(::Hash) && args[0].key?("children")
|
|
87
|
+
|
|
88
|
+
Engine.do_invoke(ctx, fn_name, parent_data, raw_params)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
DISPATCH = {
|
|
93
|
+
"Functn" => method(:functn),
|
|
94
|
+
"ParamList" => method(:param_list),
|
|
95
|
+
"Identifier" => method(:identifier),
|
|
96
|
+
"LiteralTerm" => method(:literal_term),
|
|
97
|
+
"NullLiteral" => method(:null_literal),
|
|
98
|
+
"NumberLiteral" => method(:number_literal),
|
|
99
|
+
"StringLiteral" => method(:string_literal),
|
|
100
|
+
"BooleanLiteral" => method(:boolean_literal),
|
|
101
|
+
"QuantityLiteral" => method(:quantity_literal),
|
|
102
|
+
"DateTimeLiteral" => method(:date_time_literal),
|
|
103
|
+
"TimeLiteral" => method(:time_literal),
|
|
104
|
+
"InvocationTerm" => method(:invocation_term),
|
|
105
|
+
"ParenthesizedTerm" => method(:parenthesized_term),
|
|
106
|
+
"ThisInvocation" => method(:this_invocation),
|
|
107
|
+
"IndexInvocation" => method(:index_invocation),
|
|
108
|
+
"TotalInvocation" => method(:total_invocation),
|
|
109
|
+
"ExternalConstantTerm" => method(:external_constant_term),
|
|
110
|
+
"MemberInvocation" => method(:member_invocation),
|
|
111
|
+
"FunctionInvocation" => method(:function_invocation),
|
|
112
|
+
"PolarityExpression" => method(:polarity_expression),
|
|
113
|
+
"IndexerExpression" => method(:indexer_expression),
|
|
114
|
+
"TermExpression" => method(:term_expression),
|
|
115
|
+
"InvocationExpression" => method(:invocation_expression),
|
|
116
|
+
"UnionExpression" => method(:union_expression),
|
|
117
|
+
"EqualityExpression" => method(:op_expression),
|
|
118
|
+
"InequalityExpression" => method(:op_expression),
|
|
119
|
+
"TypeExpression" => method(:type_expression),
|
|
120
|
+
"MembershipExpression" => method(:membership_expression),
|
|
121
|
+
"OrExpression" => method(:op_expression),
|
|
122
|
+
"ImpliesExpression" => method(:op_expression),
|
|
123
|
+
"AndExpression" => method(:op_expression),
|
|
124
|
+
"XorExpression" => method(:op_expression),
|
|
125
|
+
"AdditiveExpression" => method(:op_expression),
|
|
126
|
+
"MultiplicativeExpression" => method(:op_expression)
|
|
127
|
+
}.freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "math"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Invocations
|
|
8
|
+
# Ruby port of fhirpath-py's fhirpathpy/engine/invocations/aggregate.py (FHIRPath spec
|
|
9
|
+
# section 7).
|
|
10
|
+
module Aggregate
|
|
11
|
+
class << self
|
|
12
|
+
# `$total`/`$index` (see Evaluators.total_invocation/index_invocation) read
|
|
13
|
+
# ctx[:total]/ctx[:index], which this loop maintains for `expr` to see.
|
|
14
|
+
def aggregate(ctx, data, expr, initial_value = [])
|
|
15
|
+
ctx[:total] = initial_value
|
|
16
|
+
Util.arraify(data).each_with_index do |item, index|
|
|
17
|
+
ctx[:index] = index
|
|
18
|
+
ctx[:total] = expr.call(item)
|
|
19
|
+
end
|
|
20
|
+
ctx[:total]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sum(_ctx, coll)
|
|
24
|
+
coll.sum
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def avg(ctx, coll)
|
|
28
|
+
return [] if coll.empty?
|
|
29
|
+
|
|
30
|
+
Math.div(ctx, sum(ctx, coll), coll.length)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def min(_ctx, coll)
|
|
34
|
+
return [] if coll.empty?
|
|
35
|
+
|
|
36
|
+
coll.min
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def max(_ctx, coll)
|
|
40
|
+
return [] if coll.empty?
|
|
41
|
+
|
|
42
|
+
coll.max
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
module Invocations
|
|
6
|
+
# Ruby port of `contains`/`in` from fhirpath-py's
|
|
7
|
+
# fhirpathpy/engine/invocations/collections.py (FHIRPath spec 6.4.2/6.4.3).
|
|
8
|
+
module Collections
|
|
9
|
+
class << self
|
|
10
|
+
def contains(_ctx, coll, item)
|
|
11
|
+
return [] if item.empty?
|
|
12
|
+
return false if coll.empty?
|
|
13
|
+
if item.length > 1
|
|
14
|
+
raise Fhirpath::Error,
|
|
15
|
+
"Expected singleton on right side of contains, got #{item.inspect}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
coll.include?(item.first)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def in(_ctx, coll, other)
|
|
22
|
+
return [] if coll.empty?
|
|
23
|
+
return false if other.empty?
|
|
24
|
+
raise Fhirpath::Error, "Expected singleton on right side of in, got #{coll.inspect}" if coll.length > 1
|
|
25
|
+
|
|
26
|
+
other.include?(coll.first)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|