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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CLAUDE.md +59 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +43 -0
  7. data/Rakefile +12 -0
  8. data/ext/fhir_path_parser/extconf.rb +40 -0
  9. data/lib/fhirpath/engine/evaluators/expressions.rb +59 -0
  10. data/lib/fhirpath/engine/evaluators/literals.rb +70 -0
  11. data/lib/fhirpath/engine/evaluators/member_invocation.rb +109 -0
  12. data/lib/fhirpath/engine/evaluators.rb +130 -0
  13. data/lib/fhirpath/engine/invocations/aggregate.rb +48 -0
  14. data/lib/fhirpath/engine/invocations/collections.rb +32 -0
  15. data/lib/fhirpath/engine/invocations/combining.rb +30 -0
  16. data/lib/fhirpath/engine/invocations/datetime.rb +34 -0
  17. data/lib/fhirpath/engine/invocations/equality.rb +95 -0
  18. data/lib/fhirpath/engine/invocations/equivalence.rb +138 -0
  19. data/lib/fhirpath/engine/invocations/existence.rb +117 -0
  20. data/lib/fhirpath/engine/invocations/filtering.rb +107 -0
  21. data/lib/fhirpath/engine/invocations/inequality.rb +92 -0
  22. data/lib/fhirpath/engine/invocations/logic.rb +74 -0
  23. data/lib/fhirpath/engine/invocations/math.rb +109 -0
  24. data/lib/fhirpath/engine/invocations/math_functions.rb +121 -0
  25. data/lib/fhirpath/engine/invocations/misc.rb +144 -0
  26. data/lib/fhirpath/engine/invocations/misc_converts_to.rb +91 -0
  27. data/lib/fhirpath/engine/invocations/navigation.rb +102 -0
  28. data/lib/fhirpath/engine/invocations/registry_strings_and_math.rb +48 -0
  29. data/lib/fhirpath/engine/invocations/strings.rb +139 -0
  30. data/lib/fhirpath/engine/invocations/strings_encoding.rb +68 -0
  31. data/lib/fhirpath/engine/invocations/subsetting.rb +15 -0
  32. data/lib/fhirpath/engine/invocations/types.rb +36 -0
  33. data/lib/fhirpath/engine/invocations.rb +118 -0
  34. data/lib/fhirpath/engine/nodes/fp_date_time.rb +140 -0
  35. data/lib/fhirpath/engine/nodes/fp_date_time_arithmetic.rb +112 -0
  36. data/lib/fhirpath/engine/nodes/fp_quantity.rb +142 -0
  37. data/lib/fhirpath/engine/nodes/fp_quantity_deep_equal.rb +67 -0
  38. data/lib/fhirpath/engine/nodes/fp_time.rb +139 -0
  39. data/lib/fhirpath/engine/nodes/fp_time_arithmetic.rb +111 -0
  40. data/lib/fhirpath/engine/nodes/resource_node.rb +66 -0
  41. data/lib/fhirpath/engine/nodes/type_info.rb +92 -0
  42. data/lib/fhirpath/engine/param_resolution.rb +74 -0
  43. data/lib/fhirpath/engine/util.rb +117 -0
  44. data/lib/fhirpath/engine.rb +100 -0
  45. data/lib/fhirpath/models/dstu2/choiceTypePaths.json +1041 -0
  46. data/lib/fhirpath/models/dstu2/path2Type.json +4728 -0
  47. data/lib/fhirpath/models/dstu2/pathsDefinedElsewhere.json +21 -0
  48. data/lib/fhirpath/models/dstu2/type2Parent.json +141 -0
  49. data/lib/fhirpath/models/r4/choiceTypePaths.json +1365 -0
  50. data/lib/fhirpath/models/r4/path2Type.json +7707 -0
  51. data/lib/fhirpath/models/r4/pathsDefinedElsewhere.json +57 -0
  52. data/lib/fhirpath/models/r4/type2Parent.json +211 -0
  53. data/lib/fhirpath/models/r5/choiceTypePaths.json +1883 -0
  54. data/lib/fhirpath/models/r5/path2Type.json +9653 -0
  55. data/lib/fhirpath/models/r5/pathsDefinedElsewhere.json +80 -0
  56. data/lib/fhirpath/models/r5/type2Parent.json +234 -0
  57. data/lib/fhirpath/models/stu3/choiceTypePaths.json +991 -0
  58. data/lib/fhirpath/models/stu3/path2Type.json +5729 -0
  59. data/lib/fhirpath/models/stu3/pathsDefinedElsewhere.json +37 -0
  60. data/lib/fhirpath/models/stu3/type2Parent.json +173 -0
  61. data/lib/fhirpath/models.rb +25 -0
  62. data/lib/fhirpath/parser/FHIRPath.g4 +172 -0
  63. data/lib/fhirpath/parser/ast_builder.rb +51 -0
  64. data/lib/fhirpath/parser.rb +21 -0
  65. data/lib/fhirpath/version.rb +5 -0
  66. data/lib/fhirpath.rb +117 -0
  67. data/rakelib/parser.rake +146 -0
  68. data/sig/fhirpath.rbs +14 -0
  69. 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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