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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "existence"
4
+
5
+ module Fhirpath
6
+ module Engine
7
+ module Invocations
8
+ # Ruby port of `combine`/`union_op`/`coalesce_fn` from fhirpath-py's
9
+ # fhirpathpy/engine/invocations/combining.py.
10
+ module Combining
11
+ def self.combine(_ctx, coll1, coll2)
12
+ coll1 + coll2
13
+ end
14
+
15
+ def self.union(ctx, coll1, coll2)
16
+ Existence.distinct(ctx, coll1 + coll2)
17
+ end
18
+
19
+ def self.coalesce(_ctx, data, *exprs)
20
+ exprs.each do |expr|
21
+ result = expr.call(data)
22
+ return result unless Util.empty?(result)
23
+ end
24
+
25
+ []
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Engine
5
+ module Invocations
6
+ # Ruby port of `now`/`today`/`timeOfDay` from fhirpath-py's
7
+ # fhirpathpy/engine/invocations/datetime.py. fhirpath-py caches these on a module-level
8
+ # `constants` singleton reset at the start of each evaluate() call, so that repeated calls
9
+ # within one expression see the same instant; here that's just a `ctx` entry, since `ctx`
10
+ # is already fresh per evaluate() call.
11
+ module Datetime
12
+ class << self
13
+ def now(ctx, _data)
14
+ snapshot(ctx).strftime("%Y-%m-%dT%H:%M:%S.%L%:z")
15
+ end
16
+
17
+ def today(ctx, _data)
18
+ snapshot(ctx).strftime("%Y-%m-%d")
19
+ end
20
+
21
+ def time_of_day(ctx, _data)
22
+ snapshot(ctx).strftime("%H:%M:%S.%L")
23
+ end
24
+
25
+ private
26
+
27
+ def snapshot(ctx)
28
+ ctx[:now_time] ||= Time.now
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "equivalence"
4
+ require_relative "inequality"
5
+
6
+ module Fhirpath
7
+ module Engine
8
+ module Invocations
9
+ # Ruby port of `=`/`!=` from fhirpath-py's fhirpathpy/engine/invocations/equality.py.
10
+ # `~`/`!~` (equivalence) live in equivalence.rb and `<`/`>`/`<=`/`>=` in inequality.rb,
11
+ # both of which reopen this module (and use datetime_value?/coerce_datetime, below).
12
+ module Equality
13
+ class << self
14
+ def equal(_ctx, left, right)
15
+ equality(left, right)
16
+ end
17
+
18
+ def unequal(_ctx, left, right)
19
+ result = equality(left, right)
20
+ result.nil? ? nil : !result
21
+ end
22
+
23
+ private
24
+
25
+ # Per https://hl7.org/fhirpath/#equals. Returns true/false, or nil (spec: empty) when
26
+ # a DateTime/Time comparison can't be resolved at a shared precision. Collections with
27
+ # more than one item are compared pairwise, in order (fhirpath-py's own equality()
28
+ # only ever compares the first pair — a narrower reading no test here happens to
29
+ # probe — so this compares every pair instead, per the spec text quoted in this task's
30
+ # own fixture: "Each item must be equal. Comparison is order dependent.").
31
+ def equality(left, right)
32
+ return false if Util.empty?(left) || Util.empty?(right)
33
+
34
+ x0 = Util.get_data(left.first)
35
+ y0 = Util.get_data(right.first)
36
+ return datetime_equals(x0, y0) if datetime_value?(x0) || datetime_value?(y0)
37
+ return false if left.length != right.length
38
+
39
+ pairwise_equal?(left, right)
40
+ end
41
+
42
+ def pairwise_equal?(left, right)
43
+ left.zip(right).all? { |a, b| values_equal?(Util.val_data_converted(a), Util.val_data_converted(b)) }
44
+ end
45
+
46
+ # A raw FHIR Quantity element, converted to a System.Quantity, keeps its UCUM `code`
47
+ # quoted as-is (e.g. "'a'") — comparing that against a literal using the calendar
48
+ # *word* form ("year") needs FPQuantity#deep_equal instead of plain `==`; see there.
49
+ def values_equal?(left_value, right_value)
50
+ if left_value.is_a?(Nodes::FPQuantity) && right_value.is_a?(Nodes::FPQuantity) &&
51
+ Nodes::FPQuantity::CALENDAR_WORDS.include?(right_value.unit)
52
+ return left_value.deep_equal(right_value)
53
+ end
54
+
55
+ left_value == right_value
56
+ end
57
+
58
+ def datetime_value?(value)
59
+ value.is_a?(Nodes::FPDateTime) || value.is_a?(Nodes::FPTime)
60
+ end
61
+
62
+ def datetime_equals(left_value, right_value)
63
+ x = coerce_datetime(left_value)
64
+ y = coerce_datetime(right_value)
65
+ return false if x.nil? || y.nil?
66
+
67
+ x.equals(y)
68
+ end
69
+
70
+ # A string operand being compared against an already-parsed FPDateTime/FPTime is
71
+ # itself parsed as a date/time (trying DateTime first, then Time) before comparing;
72
+ # anything else (including an unparseable string) can't be compared this way.
73
+ def coerce_datetime(value)
74
+ return value if datetime_value?(value)
75
+ return nil unless value.is_a?(::String)
76
+
77
+ parse_date_time(value) || parse_time(value)
78
+ end
79
+
80
+ def parse_date_time(value)
81
+ Nodes::FPDateTime.new(value)
82
+ rescue Fhirpath::Error
83
+ nil
84
+ end
85
+
86
+ def parse_time(value)
87
+ Nodes::FPTime.new(value)
88
+ rescue Fhirpath::Error
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Engine
5
+ module Invocations
6
+ # `~`/`!~` from fhirpath-py's fhirpathpy/engine/invocations/equality.py. Split out of
7
+ # equality.rb to keep that file's module body short; reopens the same Equality module
8
+ # (datetime_value?/datetime_equals/coerce_datetime are defined there).
9
+ module Equality
10
+ class << self
11
+ def equival(_ctx, left, right)
12
+ result = equivalence(left, right)
13
+ result.nil? ? [false] : [result]
14
+ end
15
+
16
+ def unequival(_ctx, left, right)
17
+ result = equivalence(left, right)
18
+ result.nil? ? [true] : [!result]
19
+ end
20
+
21
+ private
22
+
23
+ # Per https://hl7.org/fhirpath/#equivalence. Unlike `=`, `{} ~ {}` is true, and a
24
+ # DateTime/Time precision mismatch is handled by the `[false]`/`[true]` fallback above
25
+ # rather than staying empty. Collections with more than one item are compared as a
26
+ # multiset (order-independent) — fhirpath-py's own equivalence() only ever compares
27
+ # the first pair once both collections are the same Mapping/list-shaped type, a
28
+ # narrower reading no test here happens to probe (see equality.rb's `equality` for the
29
+ # analogous `=` case).
30
+ def equivalence(left, right)
31
+ both_empty = equivalence_for_empty(left, right)
32
+ return both_empty unless both_empty.nil?
33
+
34
+ return false if left.length != right.length
35
+
36
+ x0 = Util.val_data_converted(left.first)
37
+ y0 = Util.val_data_converted(right.first)
38
+ return datetime_equals(x0, y0) if datetime_value?(x0) || datetime_value?(y0)
39
+
40
+ left.length == 1 ? value_equivalent?(x0, y0) : multiset_equivalent?(left, right)
41
+ end
42
+
43
+ def equivalence_for_empty(left, right)
44
+ return true if Util.empty?(left) && Util.empty?(right)
45
+ return false if Util.empty?(left) || Util.empty?(right)
46
+
47
+ nil
48
+ end
49
+
50
+ def multiset_equivalent?(left, right)
51
+ remaining = right.map { |item| Util.val_data_converted(item) }
52
+
53
+ left.all? do |item|
54
+ value = Util.val_data_converted(item)
55
+ index = remaining.index { |candidate| value_equivalent?(value, candidate) == true }
56
+ next false unless index
57
+
58
+ remaining.delete_at(index)
59
+ true
60
+ end
61
+ end
62
+
63
+ def value_equivalent?(left, right)
64
+ return normalize_string(left) == normalize_string(right) if both_strings?(left, right)
65
+ return numbers_equivalent?(left, right) if left.is_a?(::BigDecimal) || right.is_a?(::BigDecimal)
66
+ return left.deep_equal(right) if both_quantities?(left, right)
67
+ return deep_equivalent?(left, right) if collection_like?(left) && collection_like?(right)
68
+
69
+ left == right
70
+ end
71
+
72
+ def both_strings?(left, right)
73
+ left.is_a?(::String) && right.is_a?(::String)
74
+ end
75
+
76
+ def both_quantities?(left, right)
77
+ left.is_a?(Nodes::FPQuantity) && right.is_a?(Nodes::FPQuantity)
78
+ end
79
+
80
+ def collection_like?(value)
81
+ value.is_a?(::Hash) || value.is_a?(::Array)
82
+ end
83
+
84
+ # Structural equivalence: hash keys compared as sets (order-independent), array
85
+ # elements compared as a sorted sequence, strings via normalize_string, and numbers
86
+ # within a small tolerance — mirrors fhirpath-py's nested deep_equal.
87
+ def deep_equivalent?(left, right)
88
+ case left
89
+ when ::Hash then hashes_equivalent?(left, right)
90
+ when ::Array then arrays_equivalent?(left, right)
91
+ when ::String then right.is_a?(::String) && normalize_string(left) == normalize_string(right)
92
+ when ::Numeric then right.is_a?(::Numeric) && (left - right).abs < 0.5
93
+ else left == right
94
+ end
95
+ end
96
+
97
+ def hashes_equivalent?(left, right)
98
+ return false unless right.is_a?(::Hash) && left.keys.sort == right.keys.sort
99
+
100
+ left.keys.all? { |key| deep_equivalent?(left[key], right[key]) }
101
+ end
102
+
103
+ def arrays_equivalent?(left, right)
104
+ return false unless right.is_a?(::Array) && left.length == right.length
105
+
106
+ sort_for_compare(left).zip(sort_for_compare(right)).all? { |x, y| deep_equivalent?(x, y) }
107
+ end
108
+
109
+ def sort_for_compare(array)
110
+ array.sort
111
+ rescue ::ArgumentError
112
+ array.sort_by(&:to_s)
113
+ end
114
+
115
+ def normalize_string(value)
116
+ value.downcase.split.join(" ")
117
+ end
118
+
119
+ # Values are compared rounded to the precision of the less-precise operand (trailing
120
+ # zeroes don't count as precision) — https://hl7.org/fhirpath/#equivalence.
121
+ def numbers_equivalent?(left, right)
122
+ places = [decimal_places(left), decimal_places(right)].min
123
+ return Util.to_big_decimal(left).round == Util.to_big_decimal(right).round if places.zero?
124
+
125
+ Util.to_big_decimal(left).round(places) == Util.to_big_decimal(right).round(places)
126
+ end
127
+
128
+ def decimal_places(value)
129
+ str = Util.to_big_decimal(value).to_s("F")
130
+ return 0 unless str.include?(".")
131
+
132
+ str.split(".").last.sub(/0+\z/, "").length
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filtering"
4
+
5
+ module Fhirpath
6
+ module Engine
7
+ module Invocations
8
+ # Ruby port of fhirpath-py's fhirpathpy/engine/invocations/existence.py (FHIRPath spec
9
+ # section 5.1).
10
+ module Existence
11
+ class << self
12
+ def empty(_ctx, value)
13
+ Util.empty?(value)
14
+ end
15
+
16
+ def count(_ctx, value)
17
+ value.is_a?(::Array) ? value.length : 0
18
+ end
19
+
20
+ def not(_ctx, coll)
21
+ return [] if coll.empty?
22
+
23
+ if coll.length > 1
24
+ raise Fhirpath::Error, "Unexpected collection #{coll.inspect}; expected singleton of type Boolean"
25
+ end
26
+
27
+ !singleton_truthy?(coll.first)
28
+ end
29
+
30
+ def exists(ctx, coll, expr = nil)
31
+ return !Util.empty?(coll) if expr.nil?
32
+
33
+ exists(ctx, Filtering.where(ctx, coll, expr))
34
+ end
35
+
36
+ def all(ctx, colls, expr)
37
+ colls.each_with_index do |coll, index|
38
+ ctx[:index] = index
39
+ return [false] unless Util.true?(expr.call(coll))
40
+ end
41
+
42
+ [true]
43
+ end
44
+
45
+ def all_true(_ctx, items)
46
+ items.all? { |item| boolean_value(item) }
47
+ end
48
+
49
+ def any_true(_ctx, items)
50
+ items.any? { |item| boolean_value(item) }
51
+ end
52
+
53
+ def all_false(_ctx, items)
54
+ items.all? { |item| !boolean_value(item) }
55
+ end
56
+
57
+ def any_false(_ctx, items)
58
+ items.any? { |item| !boolean_value(item) }
59
+ end
60
+
61
+ # `coll2.include?(item)` would be asymmetric here: Ruby's String#== (unlike Python's
62
+ # equality protocol, which falls back to the other side's __eq__) just returns false
63
+ # when compared against a non-String, so a raw-string coll2 checked against a
64
+ # ResourceNode-wrapped item would wrongly report "not found" even when the underlying
65
+ # data matches. Comparing unwrapped data on both sides avoids that.
66
+ def subset_of(_ctx, coll1, coll2)
67
+ coll2_data = coll2.map { |item| Util.get_data(item) }
68
+ coll1.all? { |item| coll2_data.include?(Util.get_data(item)) }
69
+ end
70
+
71
+ def superset_of(ctx, coll1, coll2)
72
+ subset_of(ctx, coll2, coll1)
73
+ end
74
+
75
+ def distinct?(ctx, coll)
76
+ coll.length == distinct(ctx, coll).length
77
+ end
78
+
79
+ def distinct(_ctx, coll)
80
+ return distinct_resource_nodes(coll) if coll.any? && coll.all? { |v| v.is_a?(Nodes::ResourceNode) }
81
+ return distinct_quantities(coll) if coll.any? && coll.all? { |v| v.is_a?(Nodes::FPQuantity) }
82
+
83
+ Util.uniq(coll)
84
+ end
85
+
86
+ private
87
+
88
+ def boolean_value(item)
89
+ value = Util.get_data(item)
90
+ raise Fhirpath::Error, "Expected boolean, but got: #{value.inspect}" unless [true, false].include?(value)
91
+
92
+ value
93
+ end
94
+
95
+ # Mirrors fhirpath-py's boolean_singleton (used only by `not`, unlike the strict
96
+ # boolean_value above used by allTrue/anyTrue/allFalse/anyFalse): a present non-empty
97
+ # singleton coerces to `true` unless it's literally the boolean `false`.
98
+ def singleton_truthy?(item)
99
+ value = Util.get_data(item)
100
+ [true, false].include?(value) ? value : true
101
+ end
102
+
103
+ def distinct_resource_nodes(coll)
104
+ Util.uniq(coll.map(&:data)).map { |item| Nodes::ResourceNode.create_node(item) }
105
+ end
106
+
107
+ # FP_Quantity#== already treats e.g. "1 year" and "12 months" as equal
108
+ # (https://hl7.org/fhirpath/#equals); dedupe with a linear scan since quantities
109
+ # aren't otherwise hashable/canonicalizable the way Util.uniq's values are.
110
+ def distinct_quantities(coll)
111
+ coll.each_with_object([]) { |value, acc| acc << value unless acc.include?(value) }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Engine
5
+ module Invocations
6
+ # Ruby port of `where`/`select`/`repeat`/`ofType`/`single`/`first`/`last`/`tail`/`take`/
7
+ # `skip`/`extension` from fhirpath-py's fhirpathpy/engine/invocations/filtering.py.
8
+ module Filtering
9
+ class << self
10
+ def single(_ctx, coll)
11
+ return coll if coll.length == 1
12
+ return [] if coll.empty?
13
+
14
+ { "$status" => "error", "$error" => "Expected single" }
15
+ end
16
+
17
+ def first(_ctx, coll)
18
+ coll.empty? ? [] : coll.first
19
+ end
20
+
21
+ def last(_ctx, coll)
22
+ coll.empty? ? [] : coll.last
23
+ end
24
+
25
+ def tail(_ctx, coll)
26
+ coll.empty? ? [] : coll[1..]
27
+ end
28
+
29
+ def take(_ctx, coll, num)
30
+ coll.empty? ? [] : coll.first([num.to_i, 0].max)
31
+ end
32
+
33
+ def skip(_ctx, coll, num)
34
+ coll.empty? ? [] : coll.drop([num.to_i, 0].max)
35
+ end
36
+
37
+ def where(ctx, data, expr)
38
+ return [] unless data.is_a?(::Array)
39
+
40
+ result = data.each_with_index.select do |item, index|
41
+ ctx[:index] = index
42
+ truthy?(expr.call(item))
43
+ end.map(&:first)
44
+
45
+ Util.flatten(result)
46
+ end
47
+
48
+ def select(ctx, data, expr)
49
+ return [] unless data.is_a?(::Array)
50
+
51
+ result = data.each_with_index.map do |item, index|
52
+ ctx[:index] = index
53
+ expr.call(item)
54
+ end
55
+
56
+ Util.flatten(result)
57
+ end
58
+
59
+ def repeat(_ctx, data, expr)
60
+ return [] unless data.is_a?(::Array)
61
+
62
+ result = []
63
+ seen = []
64
+ items = data.dup
65
+
66
+ advance_repeat(items, seen, result, expr) until items.empty?
67
+
68
+ result
69
+ end
70
+
71
+ def of_type(ctx, coll, type_info)
72
+ coll.select { |value| Nodes::TypeInfo.from_value(value, ctx[:model]).is_(type_info, ctx[:model]) }
73
+ end
74
+
75
+ def extension(_ctx, data, url)
76
+ data.each_with_object([]) do |item, acc|
77
+ element = Util.get_data(item)
78
+ next unless element.is_a?(::Hash)
79
+
80
+ match = (element["extension"] || []).find { |ext| ext["url"] == url }
81
+ acc << Nodes::ResourceNode.create_node(match, "Extension") if match
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def truthy?(result)
88
+ return false if result.empty?
89
+
90
+ value = result.first
91
+ !(value.nil? || value == false)
92
+ end
93
+
94
+ def advance_repeat(items, seen, result, expr)
95
+ next_item = items.shift
96
+ new_items = expr.call(next_item).reject { |elem| seen.include?(elem) }
97
+ return if new_items.empty?
98
+
99
+ seen.concat(new_items)
100
+ result.concat(new_items)
101
+ items.concat(new_items)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Engine
5
+ module Invocations
6
+ # `<`/`>`/`<=`/`>=` from fhirpath-py's fhirpathpy/engine/invocations/equality.py
7
+ # (typecheck/lt/gt/lte/gte). Split out of equality.rb to keep that file's module body
8
+ # short; reopens the same Equality module (datetime_value?/coerce_datetime are defined
9
+ # there).
10
+ module Equality
11
+ class << self
12
+ def lt(_ctx, left, right)
13
+ compare(left, right) { |ordinal| ordinal == -1 }
14
+ end
15
+
16
+ def gt(_ctx, left, right)
17
+ compare(left, right) { |ordinal| ordinal == 1 }
18
+ end
19
+
20
+ def lte(_ctx, left, right)
21
+ compare(left, right) { |ordinal| ordinal <= 0 }
22
+ end
23
+
24
+ def gte(_ctx, left, right)
25
+ compare(left, right) { |ordinal| ordinal >= 0 }
26
+ end
27
+
28
+ private
29
+
30
+ # Per https://hl7.org/fhirpath/#comparison. Returns [] for an empty operand or a
31
+ # DateTime/Time comparison that can't be resolved at a shared precision.
32
+ def compare(left, right)
33
+ return [] if Util.empty?(left) || Util.empty?(right)
34
+
35
+ left_value, right_value = typecheck(left, right)
36
+ ordinal = ordinal_compare(left_value, right_value)
37
+ return [] if ordinal.nil?
38
+
39
+ yield ordinal
40
+ end
41
+
42
+ def ordinal_compare(left_value, right_value)
43
+ return left_value.compare(right_value) if datetime_value?(left_value)
44
+
45
+ left_value <=> right_value
46
+ end
47
+
48
+ # Singleton-checks both sides and requires them to be the same type (numbers are
49
+ # cross-comparable regardless of exact class); a string compared against a
50
+ # DateTime/Time is itself parsed as one first.
51
+ def typecheck(left, right)
52
+ left = Util.remove_duplicate_extension(left)
53
+ right = Util.remove_duplicate_extension(right)
54
+ check_length(left)
55
+ check_length(right)
56
+
57
+ a = Util.get_data(left.first)
58
+ b = Util.get_data(right.first)
59
+ return [a, b] if a.instance_of?(b.class) || (a.is_a?(::Numeric) && b.is_a?(::Numeric))
60
+
61
+ coerce_mismatched_types(a, b) || raise_type_mismatch(a, b)
62
+ end
63
+
64
+ def check_length(coll)
65
+ return unless coll.length > 1
66
+
67
+ raise Fhirpath::Error,
68
+ "Was expecting no more than one element but got #{coll.inspect}. Singleton was expected"
69
+ end
70
+
71
+ def coerce_mismatched_types(left, right)
72
+ return coerce_left_string(left, right) if left.is_a?(::String) && datetime_value?(right)
73
+ return coerce_left_string(right, left)&.reverse if right.is_a?(::String) && datetime_value?(left)
74
+
75
+ nil
76
+ end
77
+
78
+ def coerce_left_string(string_value, datetime)
79
+ coerced = coerce_datetime(string_value)
80
+ coerced ? [coerced, datetime] : nil
81
+ end
82
+
83
+ def raise_type_mismatch(left, right)
84
+ raise Fhirpath::Error,
85
+ "Type of \"#{left}\" (#{left.class}) did not match type of \"#{right}\" (#{right.class}). " \
86
+ "InequalityExpression"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Engine
5
+ module Invocations
6
+ # Ruby port of `and`/`or`/`xor`/`implies` from fhirpath-py's
7
+ # fhirpathpy/engine/invocations/logic.py (three-valued Boolean logic — each operand is
8
+ # `true`, `false`, or `[]` for empty, per https://hl7.org/fhirpath/#boolean-logic).
9
+ module Logic
10
+ class << self
11
+ def or_op(_ctx, left, right)
12
+ return or_with_empty_right(left) if right.is_a?(::Array)
13
+ return or_with_empty_left(right) if left.is_a?(::Array)
14
+
15
+ left || right
16
+ end
17
+
18
+ def and_op(_ctx, left, right)
19
+ return and_with_empty_right(left) if right.is_a?(::Array)
20
+ return and_with_empty_left(right) if left.is_a?(::Array)
21
+
22
+ left && right
23
+ end
24
+
25
+ def xor_op(_ctx, left, right)
26
+ return [] if left.is_a?(::Array) || right.is_a?(::Array)
27
+
28
+ (left && !right) || (!left && right)
29
+ end
30
+
31
+ def implies_op(_ctx, left, right)
32
+ return implies_with_empty_right(left) if right.is_a?(::Array)
33
+ return implies_with_empty_left(right) if left.is_a?(::Array)
34
+ return true if left == false
35
+
36
+ left && right
37
+ end
38
+
39
+ private
40
+
41
+ def or_with_empty_right(left)
42
+ return true if left == true
43
+
44
+ []
45
+ end
46
+
47
+ def or_with_empty_left(right)
48
+ right == true ? true : []
49
+ end
50
+
51
+ def and_with_empty_right(left)
52
+ return false if left == false
53
+
54
+ []
55
+ end
56
+
57
+ def and_with_empty_left(right)
58
+ right == true ? [] : false
59
+ end
60
+
61
+ def implies_with_empty_right(left)
62
+ return true if left == false
63
+
64
+ []
65
+ end
66
+
67
+ def implies_with_empty_left(right)
68
+ right == true ? true : []
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end