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,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Nodes
|
|
8
|
+
# Ruby port of fhirpath-py's FP_Quantity (fhirpathpy/engine/nodes.py): printing back as
|
|
9
|
+
# "value unit", exposing `.value` through member invocation, the calendar duration/
|
|
10
|
+
# time-unit conversion-factor equality used by `=`/`distinct`/`repeat`/`intersect`
|
|
11
|
+
# (https://hl7.org/fhirpath/#equals), the more lenient `equivalent?` used by `~`, and
|
|
12
|
+
# `conv_unit_to` (used by `toQuantity(unit)` for explicit unit conversion, and by
|
|
13
|
+
# `general_equivalent?`/`deep_equal` below for cross-unit comparisons).
|
|
14
|
+
class FPQuantity
|
|
15
|
+
# Bare calendar-duration words and already-quoted UCUM codes all normalize to the
|
|
16
|
+
# quoted UCUM code, mirroring fhirpath-py's FP_Quantity.timeUnitsToUCUM.
|
|
17
|
+
TIME_UNITS_TO_UCUM = {
|
|
18
|
+
"years" => "'a'", "months" => "'mo'", "weeks" => "'wk'", "days" => "'d'",
|
|
19
|
+
"hours" => "'h'", "minutes" => "'min'", "seconds" => "'s'", "milliseconds" => "'ms'",
|
|
20
|
+
"year" => "'a'", "month" => "'mo'", "week" => "'wk'", "day" => "'d'",
|
|
21
|
+
"hour" => "'h'", "minute" => "'min'", "second" => "'s'", "millisecond" => "'ms'",
|
|
22
|
+
"'a'" => "'a'", "'mo'" => "'mo'", "'wk'" => "'wk'", "'d'" => "'d'",
|
|
23
|
+
"'h'" => "'h'", "'min'" => "'min'", "'s'" => "'s'", "'ms'" => "'ms'"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Which quantity units are valid for date/time arithmetic (FPDateTime#plus/FPTime#plus),
|
|
27
|
+
# and which canonical duration unit each one means. Mirrors fhirpath-py's
|
|
28
|
+
# FP_Quantity._arithmetic_duration_units — deliberately narrower than TIME_UNITS_TO_UCUM
|
|
29
|
+
# above: the quoted UCUM year/month codes ("'a'"/"'mo'") are NOT valid here (only the
|
|
30
|
+
# bare words "year"/"month" are), per https://hl7.org/fhirpath/#datetime-arithmetic.
|
|
31
|
+
ARITHMETIC_DURATION_UNITS = {
|
|
32
|
+
"years" => :year, "months" => :month, "weeks" => :week, "days" => :day,
|
|
33
|
+
"hours" => :hour, "minutes" => :minute, "seconds" => :second, "milliseconds" => :millisecond,
|
|
34
|
+
"year" => :year, "month" => :month, "week" => :week, "day" => :day,
|
|
35
|
+
"hour" => :hour, "minute" => :minute, "second" => :second, "millisecond" => :millisecond,
|
|
36
|
+
"'wk'" => :week, "'d'" => :day, "'h'" => :hour,
|
|
37
|
+
"'min'" => :minute, "'s'" => :second, "'ms'" => :millisecond
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
YEAR_MONTH_FACTORS = {
|
|
41
|
+
"years" => BigDecimal("12"), "year" => BigDecimal("12"), "'a'" => BigDecimal("12"),
|
|
42
|
+
"months" => BigDecimal("1"), "month" => BigDecimal("1"), "'mo'" => BigDecimal("1")
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
TIME_FACTORS = {
|
|
46
|
+
"weeks" => BigDecimal("604800000"), "week" => BigDecimal("604800000"), "'wk'" => BigDecimal("604800000"),
|
|
47
|
+
"days" => BigDecimal("86400000"), "day" => BigDecimal("86400000"), "'d'" => BigDecimal("86400000"),
|
|
48
|
+
"hours" => BigDecimal("3600000"), "hour" => BigDecimal("3600000"), "'h'" => BigDecimal("3600000"),
|
|
49
|
+
"minutes" => BigDecimal("60000"), "minute" => BigDecimal("60000"), "'min'" => BigDecimal("60000"),
|
|
50
|
+
"seconds" => BigDecimal("1000"), "second" => BigDecimal("1000"), "'s'" => BigDecimal("1000"),
|
|
51
|
+
"milliseconds" => BigDecimal("1"), "millisecond" => BigDecimal("1"), "'ms'" => BigDecimal("1")
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# conv_unit_to's year/month table is deliberately narrower than YEAR_MONTH_FACTORS above
|
|
55
|
+
# (only the quoted UCUM codes) — matches fhirpath-py's separate _year_month_conversion_factor.
|
|
56
|
+
CONV_YEAR_MONTH_FACTORS = { "'a'" => BigDecimal("12"), "'mo'" => BigDecimal("1") }.freeze
|
|
57
|
+
|
|
58
|
+
LENGTH_FACTORS = {
|
|
59
|
+
"'m'" => BigDecimal("1"), "'cm'" => BigDecimal("0.01"), "'mm'" => BigDecimal("0.001")
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
WEIGHT_FACTORS = {
|
|
63
|
+
"'kg'" => BigDecimal("1"), "lbs" => BigDecimal("0.453592"), "'[lb_av]'" => BigDecimal("0.453592")
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
attr_reader :value, :unit
|
|
67
|
+
|
|
68
|
+
def initialize(value, unit)
|
|
69
|
+
@value = value
|
|
70
|
+
@unit = unit
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_s
|
|
74
|
+
"#{Util.format_number(value)} #{unit}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Per https://hl7.org/fhirpath/#equals, a UCUM "'a'" (annum, a fixed Julian year) is
|
|
78
|
+
# never equal to a calendar "year"/"years" even though both are 12 months for
|
|
79
|
+
# `equivalent?` purposes — only `=` makes this distinction, not `~`.
|
|
80
|
+
def ==(other)
|
|
81
|
+
return false unless other.is_a?(FPQuantity)
|
|
82
|
+
return false if year_month_category?(other) && (unit == "'a'" || other.unit == "'a'")
|
|
83
|
+
|
|
84
|
+
equivalent?(other)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def equivalent?(other)
|
|
88
|
+
return false unless other.is_a?(FPQuantity)
|
|
89
|
+
return general_equivalent?(other) unless same_convertible_category?(other)
|
|
90
|
+
|
|
91
|
+
scaled(self) == scaled(other)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.conv_unit_to(from_unit, value, to_unit)
|
|
95
|
+
convert_by_table(CONV_YEAR_MONTH_FACTORS, from_unit, value, to_unit) ||
|
|
96
|
+
convert_by_table(TIME_FACTORS, from_unit, value, to_unit) ||
|
|
97
|
+
convert_by_table(LENGTH_FACTORS, from_unit, value, to_unit) ||
|
|
98
|
+
convert_weight(from_unit, value, to_unit) ||
|
|
99
|
+
convert_mass(from_unit, value, to_unit)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.convert_by_table(factors, from_unit, value, to_unit)
|
|
103
|
+
from_factor = factors[from_unit]
|
|
104
|
+
to_factor = factors[to_unit]
|
|
105
|
+
return nil unless from_factor && to_factor
|
|
106
|
+
|
|
107
|
+
new(from_factor * value / to_factor, to_unit)
|
|
108
|
+
end
|
|
109
|
+
private_class_method :convert_by_table
|
|
110
|
+
|
|
111
|
+
def self.convert_weight(from_unit, value, to_unit)
|
|
112
|
+
from_factor = WEIGHT_FACTORS[from_unit]
|
|
113
|
+
to_factor = WEIGHT_FACTORS[to_unit]
|
|
114
|
+
return nil unless from_factor && to_factor
|
|
115
|
+
|
|
116
|
+
converted = (from_factor * value) / to_factor
|
|
117
|
+
new(converted.round(0, BigDecimal::ROUND_UP), to_unit)
|
|
118
|
+
end
|
|
119
|
+
private_class_method :convert_weight
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def exact_match?(other)
|
|
124
|
+
value == other.value && unit == other.unit
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def year_month_category?(other)
|
|
128
|
+
YEAR_MONTH_FACTORS.key?(unit) && YEAR_MONTH_FACTORS.key?(other.unit)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def same_convertible_category?(other)
|
|
132
|
+
year_month_category?(other) || (TIME_FACTORS.key?(unit) && TIME_FACTORS.key?(other.unit))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scaled(quantity)
|
|
136
|
+
factor = YEAR_MONTH_FACTORS[quantity.unit] || TIME_FACTORS[quantity.unit]
|
|
137
|
+
quantity.value * factor
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
module Nodes
|
|
6
|
+
# Reopens FPQuantity (see fp_quantity.rb) to add the mass (g/mg) conv_unit_to category,
|
|
7
|
+
# `#deep_equal` (used by `=`/`~` when comparing a raw FHIR Quantity element — converted to
|
|
8
|
+
# a System.Quantity by quoting its UCUM `code` as-is, e.g. code "a" becomes unit "'a'", see
|
|
9
|
+
# ResourceNode#convert_data — against a literal using the calendar *word* form "year";
|
|
10
|
+
# those must compare equal, unlike plain `==`, which deliberately excludes "'a'" from
|
|
11
|
+
# year/month equality), and `#equivalent?`'s general (non-year/month, non-weeks/days/time)
|
|
12
|
+
# fallback for `=`. Mirrors fhirpath-py's FP_Quantity.deep_equal and the "else" branch of
|
|
13
|
+
# FP_Quantity.__eq__; see Equality#pairwise_equal?/Equality#equivalence.
|
|
14
|
+
class FPQuantity
|
|
15
|
+
MASS_FACTORS = { "'g'" => BigDecimal("1"), "'mg'" => BigDecimal("0.001") }.freeze
|
|
16
|
+
|
|
17
|
+
# Mirrors fhirpath-py's FP_Quantity.mapUCUMCodeToTimeUnits.values() — the bare calendar
|
|
18
|
+
# words a converted-from-FHIR-Quantity's unit (always a quoted UCUM code) is compared
|
|
19
|
+
# against via #deep_equal rather than plain #==.
|
|
20
|
+
CALENDAR_WORDS = %w[year month week day hour minute second millisecond].freeze
|
|
21
|
+
|
|
22
|
+
def self.convert_mass(from_unit, value, to_unit)
|
|
23
|
+
from_factor = MASS_FACTORS[from_unit]
|
|
24
|
+
to_factor = MASS_FACTORS[to_unit]
|
|
25
|
+
return nil unless from_factor && to_factor
|
|
26
|
+
|
|
27
|
+
converted = (from_factor * value) / to_factor
|
|
28
|
+
new(converted.round(0, BigDecimal::ROUND_HALF_UP), to_unit)
|
|
29
|
+
end
|
|
30
|
+
private_class_method :convert_mass
|
|
31
|
+
|
|
32
|
+
def deep_equal(other)
|
|
33
|
+
return self == other unless other.is_a?(FPQuantity)
|
|
34
|
+
return equivalent?(other) if year_month_category?(other)
|
|
35
|
+
return self == other if unit == other.unit
|
|
36
|
+
|
|
37
|
+
round_trip_equal?(other)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def round_trip_equal?(other)
|
|
43
|
+
converted = self.class.conv_unit_to(unit, value, other.unit)
|
|
44
|
+
return self == other unless converted
|
|
45
|
+
|
|
46
|
+
reverse_converted = self.class.conv_unit_to(converted.unit, converted.value, unit)
|
|
47
|
+
return self == other unless reverse_converted
|
|
48
|
+
|
|
49
|
+
value == reverse_converted.value && unit == reverse_converted.unit
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Mirrors fhirpath-py's FP_Quantity.__eq__ "else" branch: units outside the year/month
|
|
53
|
+
# and weeks/days/time categories (e.g. length, weight, mass) are compared by converting
|
|
54
|
+
# one side to the other's unit — falling back to an exact value+unit match if they
|
|
55
|
+
# aren't in any shared convertible category at all.
|
|
56
|
+
def general_equivalent?(other)
|
|
57
|
+
return exact_match?(other) if unit == other.unit
|
|
58
|
+
|
|
59
|
+
converted = self.class.conv_unit_to(unit, value, other.unit)
|
|
60
|
+
return exact_match?(other) unless converted
|
|
61
|
+
|
|
62
|
+
other.value == converted.value && other.unit == converted.unit
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
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_Time (fhirpathpy/engine/nodes.py): printing a time
|
|
9
|
+
# literal back out verbatim, and the FHIRPath equals() comparison
|
|
10
|
+
# (https://hl7.org/fhirpath/#equals). Mirrors FPDateTime's approach (see there for the
|
|
11
|
+
# precision-comparison rationale); the leading "T" is optional since both a `@Thh:mm...`
|
|
12
|
+
# literal (stripped of "@T" before construction) and a raw "Thh:mm..." string (as stored
|
|
13
|
+
# in a resource) must parse the same. `#plus` (time + duration quantity arithmetic) lives
|
|
14
|
+
# in fp_time_arithmetic.rb, which reopens this class.
|
|
15
|
+
class FPTime
|
|
16
|
+
FORMAT = /
|
|
17
|
+
\AT?(?<hour>\d{2})
|
|
18
|
+
(?::(?<minute>\d{2})
|
|
19
|
+
(?::(?<second>\d{2})(?:\.(?<fraction>\d+))?)?
|
|
20
|
+
)?
|
|
21
|
+
(?<timezone>Z|[+-]\d{2}:\d{2})?
|
|
22
|
+
\z/x
|
|
23
|
+
|
|
24
|
+
COMPONENTS = %i[hour minute second].freeze
|
|
25
|
+
DEFAULTS = { hour: "0", minute: "0", second: "0" }.freeze
|
|
26
|
+
TIME_READERS = { 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 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
|
+
# See FPDateTime#equals for the precision-comparison rationale.
|
|
45
|
+
def equals(other)
|
|
46
|
+
return false unless other.is_a?(self.class)
|
|
47
|
+
|
|
48
|
+
fields = shared_fields(other)
|
|
49
|
+
self_precision, other_precision = precisions(other, fields)
|
|
50
|
+
|
|
51
|
+
return time_of_day == other.time_of_day if self_precision == other_precision
|
|
52
|
+
|
|
53
|
+
compare_up_to_min_precision(other, fields, [self_precision, other_precision].min)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_instant
|
|
57
|
+
Time.new(2000, 1, 1, component_value(:hour), component_value(:minute), second_value, timezone).getutc
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A timezone shift can roll the fixed reference date in to_instant over to the next (or
|
|
61
|
+
# previous) day; unlike DateTime, Time comparisons only care about wall-clock time, so
|
|
62
|
+
# this discards the date and keeps just seconds-since-midnight (with sub-second
|
|
63
|
+
# precision) — matching Python's `datetime.time()` truncation.
|
|
64
|
+
def time_of_day
|
|
65
|
+
utc = to_instant
|
|
66
|
+
(utc.hour * 3600) + (utc.min * 60) + utc.sec + utc.subsec
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# See FPDateTime#compare for the precision-comparison rationale.
|
|
70
|
+
def compare(other)
|
|
71
|
+
fields = shared_fields(other)
|
|
72
|
+
self_precision, other_precision = precisions(other, fields)
|
|
73
|
+
|
|
74
|
+
return time_of_day <=> other.time_of_day if self_precision == other_precision
|
|
75
|
+
|
|
76
|
+
compare_by_field(other, fields, [self_precision, other_precision].min)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
protected
|
|
80
|
+
|
|
81
|
+
def normalized
|
|
82
|
+
@normalized ||= begin
|
|
83
|
+
utc = to_instant
|
|
84
|
+
COMPONENTS.to_h { |c| [c, @match[c] ? utc.public_send(TIME_READERS[c]) : nil] }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def timezone?
|
|
89
|
+
!@match[:timezone].nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def shared_fields(other)
|
|
95
|
+
COMPONENTS.reject { |c| normalized[c].nil? && other.normalized[c].nil? }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def precisions(other, fields)
|
|
99
|
+
[fields.count { |c| !normalized[c].nil? }, fields.count { |c| !other.normalized[c].nil? }]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def compare_up_to_min_precision(other, fields, min_precision)
|
|
103
|
+
fields.first(min_precision).each do |c|
|
|
104
|
+
return nil if normalized[c].nil? || other.normalized[c].nil?
|
|
105
|
+
return false if normalized[c] != other.normalized[c]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return false if timezone? != other.timezone?
|
|
109
|
+
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def compare_by_field(other, fields, min_precision)
|
|
114
|
+
fields.first(min_precision).each do |c|
|
|
115
|
+
return -1 if normalized[c].nil? || other.normalized[c].nil?
|
|
116
|
+
|
|
117
|
+
ordinal = normalized[c] <=> other.normalized[c]
|
|
118
|
+
return ordinal unless ordinal.zero?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def component_value(component)
|
|
125
|
+
(@match[component] || DEFAULTS[component]).to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def second_value
|
|
129
|
+
component_value(:second) + (@match[:fraction] ? Rational("0.#{@match[:fraction]}") : 0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def timezone
|
|
133
|
+
value = @match[:timezone]
|
|
134
|
+
value.nil? || value == "Z" ? "+00:00" : value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Nodes
|
|
8
|
+
# Reopens FPTime (see fp_time.rb) to add `#plus` — see FPDateTime#plus for the overall
|
|
9
|
+
# approach. Unlike FPDateTime, arithmetic here ignores this literal's timezone entirely
|
|
10
|
+
# (adding to the wall-clock digits, keeping whatever offset was already there) and wraps
|
|
11
|
+
# past midnight — matching fhirpath-py's FP_TimeBase#plus for FP_Time, which only supports
|
|
12
|
+
# hour/minute/second/millisecond durations (never day-and-above, since a bare time has no
|
|
13
|
+
# date to carry a day rollover into).
|
|
14
|
+
class FPTime
|
|
15
|
+
def plus(quantity)
|
|
16
|
+
value = quantity.value.to_i
|
|
17
|
+
unit = FPQuantity::ARITHMETIC_DURATION_UNITS[quantity.unit]
|
|
18
|
+
raise Fhirpath::Error, "Cannot add #{quantity} to #{self}" unless time_unit?(unit)
|
|
19
|
+
|
|
20
|
+
precision = time_precision
|
|
21
|
+
delta = time_delta(unit, value, precision, quantity)
|
|
22
|
+
wrapped = (total_seconds + delta) % 86_400
|
|
23
|
+
self.class.new("T#{format_clock(wrapped, precision)}#{@match[:timezone]}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
TIME_ARITHMETIC_UNITS = %i[hour minute second millisecond].freeze
|
|
29
|
+
TIME_ARITHMETIC_FIELDS = %i[hour minute second fraction].freeze
|
|
30
|
+
|
|
31
|
+
def time_unit?(unit)
|
|
32
|
+
TIME_ARITHMETIC_UNITS.include?(unit)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def time_precision
|
|
36
|
+
TIME_ARITHMETIC_FIELDS.count { |c| @match[c] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def total_seconds
|
|
40
|
+
whole = (component_value(:hour) * 3600) + (component_value(:minute) * 60) + component_value(:second)
|
|
41
|
+
Util.to_big_decimal(whole) + (@match[:fraction] ? BigDecimal("0.#{@match[:fraction]}") : 0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Below full (hour:minute:second[.millisecond]) precision, a duration finer than the
|
|
45
|
+
# literal's own precision is converted up to whole minutes/hours first (truncating, per
|
|
46
|
+
# fhirpath-py); at full precision "second" additions keep the quantity's own fractional
|
|
47
|
+
# value (the spec: sub-second arithmetic isn't approximated).
|
|
48
|
+
def time_delta(unit, value, precision, quantity)
|
|
49
|
+
case precision
|
|
50
|
+
when 2 then time_delta_at_hour_minute_precision(unit, value)
|
|
51
|
+
when 3, 4 then time_delta_at_second_precision(unit, value, quantity)
|
|
52
|
+
else
|
|
53
|
+
raise Fhirpath::Error, "Cannot add #{unit} at precision #{precision} to #{self}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def time_delta_at_hour_minute_precision(unit, value)
|
|
58
|
+
case unit
|
|
59
|
+
when :hour then value * 3600
|
|
60
|
+
when :minute then value * 60
|
|
61
|
+
when :second then trunc_div(value, 60) * 60
|
|
62
|
+
when :millisecond then trunc_div(value, 60_000) * 60
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def time_delta_at_second_precision(unit, value, quantity)
|
|
67
|
+
case unit
|
|
68
|
+
when :hour then value * 3600
|
|
69
|
+
when :minute then value * 60
|
|
70
|
+
when :second then quantity.value
|
|
71
|
+
when :millisecond then Util.to_big_decimal(value) / 1000
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def trunc_div(numerator, denominator)
|
|
76
|
+
numerator.fdiv(denominator).truncate
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Precision 2 (hour:minute only, no seconds in the source literal) keeps the output at
|
|
80
|
+
# that same granularity — printing an explicit ":00" seconds field here would make this
|
|
81
|
+
# result's precision look finer than the original, which throws off equals()'s
|
|
82
|
+
# differing-precision comparison against another precision-2 literal (only fields both
|
|
83
|
+
# sides actually specify may be compared; an extra field one side has and the other
|
|
84
|
+
# doesn't is `nil`/empty, not a match, per https://hl7.org/fhirpath/#equals).
|
|
85
|
+
def format_clock(seconds_value, precision)
|
|
86
|
+
whole = seconds_value.to_i
|
|
87
|
+
hour = whole / 3600
|
|
88
|
+
minute = (whole % 3600) / 60
|
|
89
|
+
clock = "#{two_digits(hour)}:#{two_digits(minute)}"
|
|
90
|
+
return clock if precision == 2
|
|
91
|
+
|
|
92
|
+
"#{clock}:#{format_seconds_field(whole % 60, seconds_value - whole)}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def format_seconds_field(second, frac)
|
|
96
|
+
return two_digits(second) if frac.zero?
|
|
97
|
+
|
|
98
|
+
"#{two_digits(second)}.#{frac_digits(frac)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def two_digits(number)
|
|
102
|
+
number.to_s.rjust(2, "0")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def frac_digits(frac)
|
|
106
|
+
frac.to_s("F").sub(/\A0\./, "").sub(/0+\z/, "")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fhirpath
|
|
4
|
+
module Engine
|
|
5
|
+
module Nodes
|
|
6
|
+
# Ruby port of fhirpath-py's ResourceNode (fhirpathpy/engine/nodes.py): wraps a node of
|
|
7
|
+
# data reached while navigating a resource, tracking its FHIRPath "path" (e.g.
|
|
8
|
+
# "Patient.name"), the property name/index it was reached through, and the raw data
|
|
9
|
+
# itself.
|
|
10
|
+
class ResourceNode
|
|
11
|
+
attr_reader :data, :path, :prop_name, :index
|
|
12
|
+
|
|
13
|
+
def initialize(data, path, prop_name: nil, index: nil)
|
|
14
|
+
path = data["resourceType"] if data.is_a?(::Hash) && data.key?("resourceType")
|
|
15
|
+
|
|
16
|
+
@data = data
|
|
17
|
+
@path = path
|
|
18
|
+
@prop_name = prop_name
|
|
19
|
+
@index = index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
data == (other.is_a?(ResourceNode) ? other.data : other)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A path with no dot is already a bare type name (e.g. "Observation", or "id" once
|
|
27
|
+
# path2Type has resolved it during navigation). A dotted path with a model active means
|
|
28
|
+
# this node is a nested element with no specific named type — a FHIR BackboneElement;
|
|
29
|
+
# without a model, fall back to inferring the type from the raw Ruby value.
|
|
30
|
+
def type_info(model)
|
|
31
|
+
return nil if path.nil?
|
|
32
|
+
|
|
33
|
+
match = path.match(/\ASystem\.(.*)\z/)
|
|
34
|
+
return TypeInfo.new(match[1], TypeInfo::SYSTEM) if match
|
|
35
|
+
return TypeInfo.new(path, TypeInfo::FHIR) unless path.include?(".")
|
|
36
|
+
return TypeInfo.new("BackboneElement", TypeInfo::FHIR) if model
|
|
37
|
+
|
|
38
|
+
TypeInfo.create_by_value_in_namespace(TypeInfo::FHIR, data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A UCUM-coded quantity-shaped object (e.g. a FHIR Quantity/Duration/Age element:
|
|
42
|
+
# {"value" => ..., "unit" => ..., "system" => "http://unitsofmeasure.org", "code" => ...})
|
|
43
|
+
# resolves to an FPQuantity built from its "code", not its (human-readable) "unit". A
|
|
44
|
+
# present "comparator" (e.g. ">5 mg") makes the value an inexact bound, not a precise
|
|
45
|
+
# quantity — System.Quantity has no comparator concept, so it can't be converted.
|
|
46
|
+
def convert_data
|
|
47
|
+
return data unless data.is_a?(::Hash) && data["system"] == "http://unitsofmeasure.org"
|
|
48
|
+
|
|
49
|
+
if data.key?("comparator")
|
|
50
|
+
raise Fhirpath::Error, "Cannot convert a Quantity with a comparator to System.Quantity"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
code = data["code"]
|
|
54
|
+
unit = FPQuantity::TIME_UNITS_TO_UCUM[code] || "'#{code}'"
|
|
55
|
+
FPQuantity.new(data["value"], unit)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.create_node(data, path = nil, prop_name: nil, index: nil)
|
|
59
|
+
return data if data.is_a?(ResourceNode)
|
|
60
|
+
|
|
61
|
+
new(data, path, prop_name: prop_name, index: index)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Fhirpath
|
|
6
|
+
module Engine
|
|
7
|
+
module Nodes
|
|
8
|
+
# Ruby port of fhirpath-py's TypeInfo (fhirpathpy/engine/nodes.py), used by `ofType`/`is`/
|
|
9
|
+
# `as`. fhirpath-py stashes the active model on a `TypeInfo.model` class attribute (set as
|
|
10
|
+
# a side effect inside is_fn/as_fn, with an acknowledged "TODO: incorrect place" — it's a
|
|
11
|
+
# workaround for ResourceNode#get_type_info not otherwise having access to ctx); here the
|
|
12
|
+
# model is threaded through explicitly instead, since a class-level global would make
|
|
13
|
+
# model-less and model-bearing `is`/`as` calls interfere across evaluations run in the
|
|
14
|
+
# same process (as this gem's own test suite does).
|
|
15
|
+
class TypeInfo
|
|
16
|
+
SYSTEM = "System"
|
|
17
|
+
FHIR = "FHIR"
|
|
18
|
+
|
|
19
|
+
attr_reader :name, :namespace
|
|
20
|
+
|
|
21
|
+
def initialize(name, namespace)
|
|
22
|
+
@name = name
|
|
23
|
+
@namespace = namespace
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def is_(other, model)
|
|
27
|
+
return false unless other.is_a?(TypeInfo) && namespace_compatible?(other)
|
|
28
|
+
return name == other.name unless model && (namespace.nil? || namespace == FHIR)
|
|
29
|
+
|
|
30
|
+
self.class.subtype?(model, name, other.name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_value(value, model)
|
|
34
|
+
return value.type_info(model) if value.is_a?(ResourceNode)
|
|
35
|
+
|
|
36
|
+
create_by_value_in_namespace(SYSTEM, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Walks the model's type hierarchy (subtype -> parent, falling back to path2Type) from
|
|
40
|
+
# type_name looking for super_type.
|
|
41
|
+
def self.subtype?(model, type_name, super_type)
|
|
42
|
+
while type_name
|
|
43
|
+
return true if type_name == super_type
|
|
44
|
+
|
|
45
|
+
type_name = model.dig("type2Parent", type_name) || model.dig("path2Type", type_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.create_by_value_in_namespace(namespace, value)
|
|
52
|
+
name = type_name_for(value)
|
|
53
|
+
name = namespace == SYSTEM ? system_name(name) : name
|
|
54
|
+
new(name, namespace)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mirrors fhirpath-py's TypeInfo.create_by_value_in_namespace: every System-namespace
|
|
58
|
+
# name is capitalized, EXCEPT "dateTime" — Ruby's (and Python's) #capitalize also
|
|
59
|
+
# downcases the rest of the string, which would mangle "dateTime" into "Datetime".
|
|
60
|
+
def self.system_name(name)
|
|
61
|
+
return "DateTime" if name == "dateTime"
|
|
62
|
+
|
|
63
|
+
name.capitalize
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
TYPE_NAMES_BY_CLASS = {
|
|
67
|
+
::Integer => "integer",
|
|
68
|
+
::Float => "decimal",
|
|
69
|
+
::BigDecimal => "decimal",
|
|
70
|
+
FPDateTime => "dateTime",
|
|
71
|
+
FPTime => "time",
|
|
72
|
+
FPQuantity => "Quantity",
|
|
73
|
+
::String => "string",
|
|
74
|
+
::Hash => "object"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
def self.type_name_for(value)
|
|
78
|
+
return "boolean" if [true, false].include?(value)
|
|
79
|
+
|
|
80
|
+
matched = TYPE_NAMES_BY_CLASS.find { |klass, _name| value.is_a?(klass) }
|
|
81
|
+
matched ? matched.last : value.class.name
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def namespace_compatible?(other)
|
|
87
|
+
namespace.nil? || other.namespace.nil? || namespace == other.namespace
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|