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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "strings_encoding"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Invocations
|
|
8
|
+
# Ruby port of fhirpath-py's fhirpathpy/engine/invocations/strings.py (FHIRPath spec
|
|
9
|
+
# section 5.6). `encode`/`decode` live in strings_encoding.rb. `escape`/`unescape` aren't
|
|
10
|
+
# ported (fhirpath-py's own tests disable them too, as "not implemented yet").
|
|
11
|
+
module Strings
|
|
12
|
+
class << self
|
|
13
|
+
def index_of(_ctx, coll, substr)
|
|
14
|
+
string = ensure_string_singleton(coll)
|
|
15
|
+
string.index(substr) || -1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def substring(_ctx, coll, start, length = nil)
|
|
19
|
+
string = ensure_string_singleton(coll)
|
|
20
|
+
return [] if start.is_a?(::Array) || start.nil?
|
|
21
|
+
|
|
22
|
+
start = start.to_i
|
|
23
|
+
return [] if start.negative? || start >= string.length
|
|
24
|
+
return string[start..] if length.nil? || length.is_a?(::Array)
|
|
25
|
+
|
|
26
|
+
string[start, length.to_i]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def starts_with(_ctx, coll, prefix)
|
|
30
|
+
return [] if Util.empty?(prefix)
|
|
31
|
+
|
|
32
|
+
string = ensure_string_singleton(coll)
|
|
33
|
+
return false if string.empty? || !prefix.is_a?(::String)
|
|
34
|
+
|
|
35
|
+
string.start_with?(prefix)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ends_with(_ctx, coll, suffix)
|
|
39
|
+
return [] if Util.empty?(suffix)
|
|
40
|
+
|
|
41
|
+
string = ensure_string_singleton(coll)
|
|
42
|
+
return false if string.empty? || !suffix.is_a?(::String)
|
|
43
|
+
|
|
44
|
+
string.end_with?(suffix)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def contains(_ctx, coll, substr)
|
|
48
|
+
ensure_string_singleton(coll).include?(substr)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def upper(_ctx, coll)
|
|
52
|
+
ensure_string_singleton(coll).upcase
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def lower(_ctx, coll)
|
|
56
|
+
ensure_string_singleton(coll).downcase
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `pattern` is matched literally, not as a regex — see `replace_matches` for the
|
|
60
|
+
# regex-based counterpart.
|
|
61
|
+
def replace(_ctx, coll, pattern, repl)
|
|
62
|
+
string = ensure_string_singleton(coll)
|
|
63
|
+
return repl + string.chars.join(repl) + repl if pattern == "" && repl.is_a?(::String)
|
|
64
|
+
return [] if blank?(string) || blank?(pattern)
|
|
65
|
+
|
|
66
|
+
string.gsub(pattern, repl)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def matches(_ctx, coll, regex)
|
|
70
|
+
return [] if blank?(regex) || coll.empty?
|
|
71
|
+
|
|
72
|
+
string = ensure_string_singleton(coll)
|
|
73
|
+
string.match?(Regexp.new(regex, Regexp::MULTILINE))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def replace_matches(_ctx, coll, regex, repl)
|
|
77
|
+
string = ensure_string_singleton(coll)
|
|
78
|
+
return [] if regex.is_a?(::Array) || repl.is_a?(::Array)
|
|
79
|
+
|
|
80
|
+
# FHIRPath substitution refers to capture groups as $1, $2, ...; Ruby (like Python)
|
|
81
|
+
# spells that \1, \2, ... in a gsub replacement string.
|
|
82
|
+
substitution = repl.gsub(/\$(\d+)/) { "\\#{::Regexp.last_match(1)}" }
|
|
83
|
+
string.gsub(Regexp.new(regex), substitution)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def length(_ctx, coll)
|
|
87
|
+
ensure_string_singleton(coll).length
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_chars(_ctx, coll)
|
|
91
|
+
return [] if coll.empty?
|
|
92
|
+
|
|
93
|
+
ensure_string_singleton(coll).chars
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def split(_ctx, coll, delimiter)
|
|
97
|
+
# -1 keeps trailing empty fields, matching Python's str.split (Ruby's split drops
|
|
98
|
+
# them by default).
|
|
99
|
+
ensure_string_singleton(coll).split(delimiter, -1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trim(_ctx, coll)
|
|
103
|
+
ensure_string_singleton(coll).strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def join(_ctx, coll, separator = "")
|
|
107
|
+
return [] if coll.empty?
|
|
108
|
+
|
|
109
|
+
coll.map { |item| string_value(item) }.join(separator)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def ensure_string_singleton(coll)
|
|
115
|
+
if coll.length == 1
|
|
116
|
+
data = Util.get_data(coll.first)
|
|
117
|
+
return data if data.is_a?(::String)
|
|
118
|
+
|
|
119
|
+
raise Fhirpath::Error, "Expected string, but got #{data.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
raise Fhirpath::Error, "Expected string, but got #{coll.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def string_value(item)
|
|
126
|
+
data = Util.get_data(item)
|
|
127
|
+
raise Fhirpath::Error, "Join requires a collection of strings." unless data.is_a?(::String)
|
|
128
|
+
|
|
129
|
+
data
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def blank?(value)
|
|
133
|
+
value.respond_to?(:empty?) ? value.empty? : value.nil?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Invocations
|
|
8
|
+
# `encode`/`decode` from fhirpath-py's fhirpathpy/engine/invocations/strings.py. Split
|
|
9
|
+
# out of strings.rb to keep that file's module body short; reopens the same Strings
|
|
10
|
+
# module.
|
|
11
|
+
module Strings
|
|
12
|
+
class << self
|
|
13
|
+
def encode(_ctx, coll, encoding)
|
|
14
|
+
return [] if coll.empty?
|
|
15
|
+
|
|
16
|
+
value = Util.get_data(coll.first)
|
|
17
|
+
return [] if blank?(value)
|
|
18
|
+
|
|
19
|
+
encode_value(value, encoding)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decode(_ctx, coll, encoding)
|
|
23
|
+
return [] if coll.empty?
|
|
24
|
+
|
|
25
|
+
value = Util.get_data(coll.first)
|
|
26
|
+
return [] if blank?(value)
|
|
27
|
+
|
|
28
|
+
decode_value(value, encoding)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def encode_value(value, encoding)
|
|
34
|
+
case encoding
|
|
35
|
+
when "urlbase64", "base64url"
|
|
36
|
+
Base64.strict_encode64(value).tr("+/", "-_")
|
|
37
|
+
when "base64"
|
|
38
|
+
Base64.strict_encode64(value)
|
|
39
|
+
when "hex"
|
|
40
|
+
value.each_byte.map { |byte| byte.to_s(16).rjust(2, "0") }.join
|
|
41
|
+
else
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def decode_value(value, encoding)
|
|
47
|
+
case encoding
|
|
48
|
+
when "urlbase64", "base64url"
|
|
49
|
+
Base64.strict_decode64(value.tr("-_", "+/")).force_encoding(::Encoding::UTF_8)
|
|
50
|
+
when "base64"
|
|
51
|
+
Base64.strict_decode64(value).force_encoding(::Encoding::UTF_8)
|
|
52
|
+
when "hex"
|
|
53
|
+
decode_hex(value)
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def decode_hex(value)
|
|
60
|
+
raise Fhirpath::Error, "Decode 'hex' requires an even number of characters." if value.length.odd?
|
|
61
|
+
|
|
62
|
+
value.each_char.each_slice(2).map { |pair| pair.join.to_i(16).chr }.join
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
module Invocations
|
|
6
|
+
# Ruby port of `intersect` from fhirpath-py's fhirpathpy/engine/invocations/subsetting.py.
|
|
7
|
+
module Subsetting
|
|
8
|
+
def self.intersect(_ctx, coll1, coll2)
|
|
9
|
+
intersection = coll1.select { |item| coll2.include?(item) }
|
|
10
|
+
intersection.each_with_object([]) { |item, acc| acc << item unless acc.include?(item) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
module Invocations
|
|
6
|
+
# Ruby port of `is`/`as`/`type` from fhirpath-py's fhirpathpy/engine/invocations/types.py.
|
|
7
|
+
module Types
|
|
8
|
+
class << self
|
|
9
|
+
# Each item's TypeInfo, as a plain Hash so ".name"/".namespace" member navigation
|
|
10
|
+
# (see evaluators/member_invocation.rb's generic Hash handling) works on the result —
|
|
11
|
+
# mirrors fhirpath-py's type_fn returning TypeInfo.__dict__ for each value.
|
|
12
|
+
def type(ctx, coll)
|
|
13
|
+
coll.map do |item|
|
|
14
|
+
type_info = Nodes::TypeInfo.from_value(item, ctx[:model])
|
|
15
|
+
{ "name" => type_info.name, "namespace" => type_info.namespace }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def is(ctx, coll, type_info)
|
|
20
|
+
return [] if coll.empty?
|
|
21
|
+
raise Fhirpath::Error, "Expected singleton on left side of 'is', got #{coll.inspect}" if coll.length > 1
|
|
22
|
+
|
|
23
|
+
Nodes::TypeInfo.from_value(coll.first, ctx[:model]).is_(type_info, ctx[:model])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def as(ctx, coll, type_info)
|
|
27
|
+
return [] if coll.empty?
|
|
28
|
+
raise Fhirpath::Error, "Expected singleton on left side of 'as', got #{coll.inspect}" if coll.length > 1
|
|
29
|
+
|
|
30
|
+
Nodes::TypeInfo.from_value(coll.first, ctx[:model]).is_(type_info, ctx[:model]) ? coll : []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "invocations/navigation"
|
|
4
|
+
require_relative "invocations/filtering"
|
|
5
|
+
require_relative "invocations/equality"
|
|
6
|
+
require_relative "invocations/existence"
|
|
7
|
+
require_relative "invocations/combining"
|
|
8
|
+
require_relative "invocations/math"
|
|
9
|
+
require_relative "invocations/subsetting"
|
|
10
|
+
require_relative "invocations/misc"
|
|
11
|
+
require_relative "invocations/misc_converts_to"
|
|
12
|
+
require_relative "invocations/strings"
|
|
13
|
+
require_relative "invocations/datetime"
|
|
14
|
+
require_relative "invocations/types"
|
|
15
|
+
require_relative "invocations/collections"
|
|
16
|
+
require_relative "invocations/logic"
|
|
17
|
+
require_relative "invocations/aggregate"
|
|
18
|
+
require_relative "invocations/registry_strings_and_math"
|
|
19
|
+
|
|
20
|
+
module Fhirpath
|
|
21
|
+
module Engine
|
|
22
|
+
module Invocations
|
|
23
|
+
# Ruby port of the base `invocation_registry` built in fhirpath-py's
|
|
24
|
+
# fhirpathpy/engine/invocations/__init__.py. Only entries needed so far are ported; see
|
|
25
|
+
# the category modules required above. Split across two constants purely to keep this
|
|
26
|
+
# module's line count down — REGISTRY_STRINGS_AND_MATH (the string functions and the
|
|
27
|
+
# single-argument math functions) lives in registry_strings_and_math.rb, which reopens
|
|
28
|
+
# this module.
|
|
29
|
+
REGISTRY_CORE = {
|
|
30
|
+
"where" => { fn: Filtering.method(:where), arity: { 1 => ["Expr"] } },
|
|
31
|
+
"select" => { fn: Filtering.method(:select), arity: { 1 => ["Expr"] } },
|
|
32
|
+
"repeat" => { fn: Filtering.method(:repeat), arity: { 1 => ["Expr"] } },
|
|
33
|
+
"ofType" => { fn: Filtering.method(:of_type), arity: { 1 => ["TypeSpecifier"] } },
|
|
34
|
+
"extension" => { fn: Filtering.method(:extension), arity: { 1 => ["String"] } },
|
|
35
|
+
"type" => { fn: Types.method(:type) },
|
|
36
|
+
"is" => { fn: Types.method(:is), arity: { 1 => ["TypeSpecifier"] } },
|
|
37
|
+
"as" => { fn: Types.method(:as), arity: { 1 => ["TypeSpecifier"] } },
|
|
38
|
+
"isOp" => { fn: Types.method(:is), arity: { 2 => %w[Any TypeSpecifier] } },
|
|
39
|
+
"asOp" => { fn: Types.method(:as), arity: { 2 => %w[Any TypeSpecifier] } },
|
|
40
|
+
"single" => { fn: Filtering.method(:single) },
|
|
41
|
+
"first" => { fn: Filtering.method(:first) },
|
|
42
|
+
"last" => { fn: Filtering.method(:last) },
|
|
43
|
+
"tail" => { fn: Filtering.method(:tail) },
|
|
44
|
+
"take" => { fn: Filtering.method(:take), arity: { 1 => ["Integer"] } },
|
|
45
|
+
"skip" => { fn: Filtering.method(:skip), arity: { 1 => ["Integer"] } },
|
|
46
|
+
"children" => { fn: Navigation.method(:children) },
|
|
47
|
+
"descendants" => { fn: Navigation.method(:descendants) },
|
|
48
|
+
"combine" => { fn: Combining.method(:combine), arity: { 1 => ["AnyAtRoot"] } },
|
|
49
|
+
"coalesce" => { fn: Combining.method(:coalesce), variadic: "Expr" },
|
|
50
|
+
"|" => { fn: Combining.method(:union), arity: { 2 => %w[Any Any] } },
|
|
51
|
+
"union" => { fn: Combining.method(:union), arity: { 1 => ["AnyAtRoot"] } },
|
|
52
|
+
"intersect" => { fn: Subsetting.method(:intersect), arity: { 1 => ["AnyAtRoot"] } },
|
|
53
|
+
"empty" => { fn: Existence.method(:empty) },
|
|
54
|
+
"not" => { fn: Existence.method(:not) },
|
|
55
|
+
"exists" => { fn: Existence.method(:exists), arity: { 0 => [], 1 => ["Expr"] } },
|
|
56
|
+
"all" => { fn: Existence.method(:all), arity: { 1 => ["Expr"] } },
|
|
57
|
+
"allTrue" => { fn: Existence.method(:all_true) },
|
|
58
|
+
"anyTrue" => { fn: Existence.method(:any_true) },
|
|
59
|
+
"allFalse" => { fn: Existence.method(:all_false) },
|
|
60
|
+
"anyFalse" => { fn: Existence.method(:any_false) },
|
|
61
|
+
"subsetOf" => { fn: Existence.method(:subset_of), arity: { 1 => ["AnyAtRoot"] } },
|
|
62
|
+
"supersetOf" => { fn: Existence.method(:superset_of), arity: { 1 => ["AnyAtRoot"] } },
|
|
63
|
+
"isDistinct" => { fn: Existence.method(:distinct?) },
|
|
64
|
+
"distinct" => { fn: Existence.method(:distinct) },
|
|
65
|
+
"count" => { fn: Existence.method(:count) },
|
|
66
|
+
"=" => { fn: Equality.method(:equal), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
67
|
+
"!=" => { fn: Equality.method(:unequal), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
68
|
+
"~" => { fn: Equality.method(:equival), arity: { 2 => %w[Any Any] } },
|
|
69
|
+
"!~" => { fn: Equality.method(:unequival), arity: { 2 => %w[Any Any] } },
|
|
70
|
+
"<" => { fn: Equality.method(:lt), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
71
|
+
">" => { fn: Equality.method(:gt), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
72
|
+
"<=" => { fn: Equality.method(:lte), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
73
|
+
">=" => { fn: Equality.method(:gte), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
74
|
+
"containsOp" => { fn: Collections.method(:contains), arity: { 2 => %w[Any Any] } },
|
|
75
|
+
"inOp" => { fn: Collections.method(:in), arity: { 2 => %w[Any Any] } },
|
|
76
|
+
"or" => { fn: Logic.method(:or_op), arity: { 2 => %w[Boolean Boolean] } },
|
|
77
|
+
"and" => { fn: Logic.method(:and_op), arity: { 2 => %w[Boolean Boolean] } },
|
|
78
|
+
"xor" => { fn: Logic.method(:xor_op), arity: { 2 => %w[Boolean Boolean] } },
|
|
79
|
+
"implies" => { fn: Logic.method(:implies_op), arity: { 2 => %w[Boolean Boolean] } },
|
|
80
|
+
"+" => { fn: Math.method(:plus), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
81
|
+
"-" => { fn: Math.method(:minus), arity: { 2 => %w[Any Any] }, nullable: true },
|
|
82
|
+
"*" => { fn: Math.method(:mul), arity: { 2 => %w[Number Number] }, nullable: true },
|
|
83
|
+
"/" => { fn: Math.method(:div), arity: { 2 => %w[Number Number] }, nullable: true },
|
|
84
|
+
"div" => { fn: Math.method(:intdiv), arity: { 2 => %w[Number Number] }, nullable: true },
|
|
85
|
+
"mod" => { fn: Math.method(:mod), arity: { 2 => %w[Number Number] }, nullable: true },
|
|
86
|
+
"&" => { fn: Math.method(:amp), arity: { 2 => %w[String String] } },
|
|
87
|
+
"iif" => { fn: Misc.method(:iif), arity: { 2 => %w[Expr Expr], 3 => %w[Expr Expr Expr] } },
|
|
88
|
+
"trace" => { fn: Misc.method(:trace), arity: { 0 => [], 1 => ["String"], 2 => %w[String Expr] } },
|
|
89
|
+
"toInteger" => { fn: Misc.method(:to_integer) },
|
|
90
|
+
"toDecimal" => { fn: Misc.method(:to_decimal) },
|
|
91
|
+
"toString" => { fn: Misc.method(:to_string) },
|
|
92
|
+
"toDate" => { fn: Misc.method(:to_date) },
|
|
93
|
+
"toDateTime" => { fn: Misc.method(:to_date_time) },
|
|
94
|
+
"toTime" => { fn: Misc.method(:to_time) },
|
|
95
|
+
"toQuantity" => { fn: Misc.method(:to_quantity), arity: { 0 => [], 1 => ["String"] } },
|
|
96
|
+
"toBoolean" => { fn: Misc.method(:to_boolean) },
|
|
97
|
+
"convertsToBoolean" => { fn: Misc.method(:converts_to_boolean) },
|
|
98
|
+
"convertsToInteger" => { fn: Misc.method(:converts_to_integer) },
|
|
99
|
+
"convertsToDecimal" => { fn: Misc.method(:converts_to_decimal) },
|
|
100
|
+
"convertsToString" => { fn: Misc.method(:converts_to_string) },
|
|
101
|
+
"convertsToDate" => { fn: Misc.method(:converts_to_date) },
|
|
102
|
+
"convertsToDateTime" => { fn: Misc.method(:converts_to_date_time) },
|
|
103
|
+
"convertsToTime" => { fn: Misc.method(:converts_to_time) },
|
|
104
|
+
"convertsToQuantity" => { fn: Misc.method(:converts_to_quantity) },
|
|
105
|
+
"aggregate" => { fn: Aggregate.method(:aggregate), arity: { 1 => ["Expr"], 2 => %w[Expr Any] } },
|
|
106
|
+
"sum" => { fn: Aggregate.method(:sum) },
|
|
107
|
+
"avg" => { fn: Aggregate.method(:avg) },
|
|
108
|
+
"min" => { fn: Aggregate.method(:min) },
|
|
109
|
+
"max" => { fn: Aggregate.method(:max) },
|
|
110
|
+
"now" => { fn: Datetime.method(:now) },
|
|
111
|
+
"today" => { fn: Datetime.method(:today) },
|
|
112
|
+
"timeOfDay" => { fn: Datetime.method(:time_of_day) }
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
115
|
+
REGISTRY = REGISTRY_CORE.merge(REGISTRY_STRINGS_AND_MATH).freeze
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Nodes
|
|
8
|
+
# Minimal Ruby port of fhirpath-py's FP_DateTime (fhirpathpy/engine/nodes.py): printing a
|
|
9
|
+
# date/time literal back out verbatim, and the FHIRPath equals() comparison
|
|
10
|
+
# (https://hl7.org/fhirpath/#equals): each specified precision level is compared in turn,
|
|
11
|
+
# starting from year; a mismatch there is `false`, but if one side simply doesn't specify
|
|
12
|
+
# a precision level the other does, the result is empty (`nil` here) rather than `false`.
|
|
13
|
+
# `#plus` (date/time + duration quantity arithmetic) lives in fp_date_time_arithmetic.rb,
|
|
14
|
+
# which reopens this class.
|
|
15
|
+
class FPDateTime
|
|
16
|
+
FORMAT = /
|
|
17
|
+
\A(?<year>\d{4})
|
|
18
|
+
(?:-(?<month>\d{2})(?:-(?<day>\d{2}))?)?
|
|
19
|
+
(?:T(?<hour>\d{2})(?::(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d+))?)?)?
|
|
20
|
+
(?<timezone>Z|[+-]\d{2}:\d{2})?
|
|
21
|
+
)?\z
|
|
22
|
+
/x
|
|
23
|
+
|
|
24
|
+
COMPONENTS = %i[year month day hour minute second].freeze
|
|
25
|
+
DEFAULTS = { year: "0", month: "1", day: "1", hour: "0", minute: "0", second: "0" }.freeze
|
|
26
|
+
TIME_READERS = { year: :year, month: :month, day: :day, hour: :hour, minute: :min, second: :sec }.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :source
|
|
29
|
+
|
|
30
|
+
def initialize(source)
|
|
31
|
+
@source = source
|
|
32
|
+
@match = FORMAT.match(source)
|
|
33
|
+
raise Fhirpath::Error, "Invalid date/time literal: #{source}" unless @match
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
source
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ==(other)
|
|
41
|
+
equals(other) == true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Per https://hl7.org/fhirpath/#equals: compare each precision level present on either
|
|
45
|
+
# side, starting from year. A field both sides specify but disagree on is `false`; a
|
|
46
|
+
# field only one side specifies is `nil` (spec: empty). If every field up to the lower
|
|
47
|
+
# precision matches and both sides specify the same precision, fall back to comparing
|
|
48
|
+
# the full instant (this is what distinguishes e.g. differing sub-second precision).
|
|
49
|
+
def equals(other)
|
|
50
|
+
return false unless other.is_a?(self.class)
|
|
51
|
+
|
|
52
|
+
fields = shared_fields(other)
|
|
53
|
+
self_precision, other_precision = precisions(other, fields)
|
|
54
|
+
|
|
55
|
+
return to_instant == other.to_instant if self_precision == other_precision
|
|
56
|
+
|
|
57
|
+
compare_up_to_min_precision(other, fields, [self_precision, other_precision].min)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_instant
|
|
61
|
+
Time.new(component_value(:year), component_value(:month), component_value(:day),
|
|
62
|
+
component_value(:hour), component_value(:minute), second_value, timezone).getutc
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Per https://hl7.org/fhirpath/#comparison: -1/0/1, or nil (spec: empty) when ordering
|
|
66
|
+
# can't be resolved at a shared precision (fields match up to the lower precision, but
|
|
67
|
+
# neither is a prefix of the other so which sorts first is undefined).
|
|
68
|
+
def compare(other)
|
|
69
|
+
fields = shared_fields(other)
|
|
70
|
+
self_precision, other_precision = precisions(other, fields)
|
|
71
|
+
|
|
72
|
+
return to_instant <=> other.to_instant if self_precision == other_precision
|
|
73
|
+
|
|
74
|
+
compare_by_field(other, fields, [self_precision, other_precision].min)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
protected
|
|
78
|
+
|
|
79
|
+
# UTC-shifted calendar fields, revealing only the components this literal actually
|
|
80
|
+
# specifies — lets e.g. "22:00-04:00" and "06:00+04:00" compare correctly across the
|
|
81
|
+
# day boundary the timezone shift crosses.
|
|
82
|
+
def normalized
|
|
83
|
+
@normalized ||= begin
|
|
84
|
+
utc = to_instant
|
|
85
|
+
COMPONENTS.to_h { |c| [c, @match[c] ? utc.public_send(TIME_READERS[c]) : nil] }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def timezone?
|
|
90
|
+
!@match[:timezone].nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def shared_fields(other)
|
|
96
|
+
COMPONENTS.reject { |c| normalized[c].nil? && other.normalized[c].nil? }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def precisions(other, fields)
|
|
100
|
+
[fields.count { |c| !normalized[c].nil? }, fields.count { |c| !other.normalized[c].nil? }]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compare_up_to_min_precision(other, fields, min_precision)
|
|
104
|
+
fields.first(min_precision).each do |c|
|
|
105
|
+
return nil if normalized[c].nil? || other.normalized[c].nil?
|
|
106
|
+
return false if normalized[c] != other.normalized[c]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return false if timezone? != other.timezone?
|
|
110
|
+
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compare_by_field(other, fields, min_precision)
|
|
115
|
+
fields.first(min_precision).each do |c|
|
|
116
|
+
return -1 if normalized[c].nil? || other.normalized[c].nil?
|
|
117
|
+
|
|
118
|
+
ordinal = normalized[c] <=> other.normalized[c]
|
|
119
|
+
return ordinal unless ordinal.zero?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def component_value(component)
|
|
126
|
+
(@match[component] || DEFAULTS[component]).to_i
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def second_value
|
|
130
|
+
component_value(:second) + (@match[:fraction] ? Rational("0.#{@match[:fraction]}") : 0)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def timezone
|
|
134
|
+
value = @match[:timezone]
|
|
135
|
+
value.nil? || value == "Z" ? "+00:00" : value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Nodes
|
|
8
|
+
# Reopens FPDateTime (see fp_date_time.rb) to add `#plus` — ports fhirpath-py's
|
|
9
|
+
# FP_TimeBase#plus for FP_DateTime (https://hl7.org/fhirpath/#datetime-arithmetic): adding
|
|
10
|
+
# a duration quantity respects this literal's own precision — a quantity finer than what's
|
|
11
|
+
# specified is converted up to that precision using calendar approximations (365 days/
|
|
12
|
+
# year, 30 days/month, ...) rather than exact calendar math, per the spec's rules for
|
|
13
|
+
# imprecise date/time arithmetic. `value` is always truncated to an integer first (the
|
|
14
|
+
# spec: "the decimal portion... is ignored").
|
|
15
|
+
class FPDateTime
|
|
16
|
+
PRECISION_FORMATS = {
|
|
17
|
+
1 => "%Y", 2 => "%Y-%m", 3 => "%Y-%m-%d",
|
|
18
|
+
4 => "%Y-%m-%dT%H", 5 => "%Y-%m-%dT%H:%M", 6 => "%Y-%m-%dT%H:%M:%S"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def plus(quantity)
|
|
22
|
+
value = quantity.value.to_i
|
|
23
|
+
unit = FPQuantity::ARITHMETIC_DURATION_UNITS[quantity.unit]
|
|
24
|
+
raise Fhirpath::Error, "Cannot add #{quantity} to #{self}" unless unit
|
|
25
|
+
|
|
26
|
+
precision = date_precision
|
|
27
|
+
result = add_duration(base_time, unit, value, precision)
|
|
28
|
+
self.class.new(result.strftime(PRECISION_FORMATS.fetch(precision, PRECISION_FORMATS[6])))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
DATE_ARITHMETIC_FIELDS = %i[year month day hour minute second fraction].freeze
|
|
34
|
+
SUB_HOUR_DIVISORS = { minute: 60, second: 3600, millisecond: 3_600_000 }.freeze
|
|
35
|
+
|
|
36
|
+
def date_precision
|
|
37
|
+
DATE_ARITHMETIC_FIELDS.count { |c| @match[c] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def base_time
|
|
41
|
+
Time.utc(component_value(:year), component_value(:month), component_value(:day),
|
|
42
|
+
component_value(:hour), component_value(:minute), component_value(:second))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_duration(base, unit, value, precision)
|
|
46
|
+
case unit
|
|
47
|
+
when :year then add_months(base, value * 12)
|
|
48
|
+
when :month then add_months(base, value)
|
|
49
|
+
when :day, :week then add_day_or_week(base, unit, value, precision)
|
|
50
|
+
when :hour then add_hour(base, value, precision)
|
|
51
|
+
when :minute, :second, :millisecond then add_sub_hour(base, unit, value, precision)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_day_or_week(base, unit, value, precision)
|
|
56
|
+
days_value = unit == :week ? value * 7 : value
|
|
57
|
+
case precision
|
|
58
|
+
when 1 then add_months(base, trunc_div(days_value, 365) * 12)
|
|
59
|
+
when 2 then add_months(base, trunc_div(days_value, 30))
|
|
60
|
+
else base + (days_value * 86_400)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_hour(base, value, precision)
|
|
65
|
+
case precision
|
|
66
|
+
when 2 then add_months(base, trunc_div(value, 24 * 30))
|
|
67
|
+
when 3 then base + (trunc_div(value, 24) * 86_400)
|
|
68
|
+
when 7 then base + (value * 3600)
|
|
69
|
+
else
|
|
70
|
+
raise Fhirpath::Error, "Cannot add hours at precision #{precision} to #{self}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_sub_hour(base, unit, value, precision)
|
|
75
|
+
case precision
|
|
76
|
+
when 4 then base + (trunc_div(value, SUB_HOUR_DIVISORS[unit]) * 3600)
|
|
77
|
+
when 5 then add_sub_hour_at_minute_precision(base, unit, value)
|
|
78
|
+
when 7 then add_sub_hour_at_full_precision(base, unit, value)
|
|
79
|
+
else
|
|
80
|
+
raise Fhirpath::Error, "Cannot add #{unit} at precision #{precision} to #{self}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_sub_hour_at_minute_precision(base, unit, value)
|
|
85
|
+
minutes = case unit
|
|
86
|
+
when :minute then value
|
|
87
|
+
when :second then trunc_div(value, 60)
|
|
88
|
+
when :millisecond then trunc_div(value, 60_000)
|
|
89
|
+
end
|
|
90
|
+
base + (minutes * 60)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def add_sub_hour_at_full_precision(base, unit, value)
|
|
94
|
+
case unit
|
|
95
|
+
when :minute then base + (value * 60)
|
|
96
|
+
when :second then base + value
|
|
97
|
+
when :millisecond then base + (value / 1000.0)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_months(time, months)
|
|
102
|
+
date = Date.new(time.year, time.month, time.day) >> months
|
|
103
|
+
Time.utc(date.year, date.month, date.day, time.hour, time.min, time.sec)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def trunc_div(numerator, denominator)
|
|
107
|
+
numerator.fdiv(denominator).truncate
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|