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
|
@@ -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
|