fhirpath-rb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CLAUDE.md +59 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +43 -0
  7. data/Rakefile +12 -0
  8. data/ext/fhir_path_parser/extconf.rb +40 -0
  9. data/lib/fhirpath/engine/evaluators/expressions.rb +59 -0
  10. data/lib/fhirpath/engine/evaluators/literals.rb +70 -0
  11. data/lib/fhirpath/engine/evaluators/member_invocation.rb +109 -0
  12. data/lib/fhirpath/engine/evaluators.rb +130 -0
  13. data/lib/fhirpath/engine/invocations/aggregate.rb +48 -0
  14. data/lib/fhirpath/engine/invocations/collections.rb +32 -0
  15. data/lib/fhirpath/engine/invocations/combining.rb +30 -0
  16. data/lib/fhirpath/engine/invocations/datetime.rb +34 -0
  17. data/lib/fhirpath/engine/invocations/equality.rb +95 -0
  18. data/lib/fhirpath/engine/invocations/equivalence.rb +138 -0
  19. data/lib/fhirpath/engine/invocations/existence.rb +117 -0
  20. data/lib/fhirpath/engine/invocations/filtering.rb +107 -0
  21. data/lib/fhirpath/engine/invocations/inequality.rb +92 -0
  22. data/lib/fhirpath/engine/invocations/logic.rb +74 -0
  23. data/lib/fhirpath/engine/invocations/math.rb +109 -0
  24. data/lib/fhirpath/engine/invocations/math_functions.rb +121 -0
  25. data/lib/fhirpath/engine/invocations/misc.rb +144 -0
  26. data/lib/fhirpath/engine/invocations/misc_converts_to.rb +91 -0
  27. data/lib/fhirpath/engine/invocations/navigation.rb +102 -0
  28. data/lib/fhirpath/engine/invocations/registry_strings_and_math.rb +48 -0
  29. data/lib/fhirpath/engine/invocations/strings.rb +139 -0
  30. data/lib/fhirpath/engine/invocations/strings_encoding.rb +68 -0
  31. data/lib/fhirpath/engine/invocations/subsetting.rb +15 -0
  32. data/lib/fhirpath/engine/invocations/types.rb +36 -0
  33. data/lib/fhirpath/engine/invocations.rb +118 -0
  34. data/lib/fhirpath/engine/nodes/fp_date_time.rb +140 -0
  35. data/lib/fhirpath/engine/nodes/fp_date_time_arithmetic.rb +112 -0
  36. data/lib/fhirpath/engine/nodes/fp_quantity.rb +142 -0
  37. data/lib/fhirpath/engine/nodes/fp_quantity_deep_equal.rb +67 -0
  38. data/lib/fhirpath/engine/nodes/fp_time.rb +139 -0
  39. data/lib/fhirpath/engine/nodes/fp_time_arithmetic.rb +111 -0
  40. data/lib/fhirpath/engine/nodes/resource_node.rb +66 -0
  41. data/lib/fhirpath/engine/nodes/type_info.rb +92 -0
  42. data/lib/fhirpath/engine/param_resolution.rb +74 -0
  43. data/lib/fhirpath/engine/util.rb +117 -0
  44. data/lib/fhirpath/engine.rb +100 -0
  45. data/lib/fhirpath/models/dstu2/choiceTypePaths.json +1041 -0
  46. data/lib/fhirpath/models/dstu2/path2Type.json +4728 -0
  47. data/lib/fhirpath/models/dstu2/pathsDefinedElsewhere.json +21 -0
  48. data/lib/fhirpath/models/dstu2/type2Parent.json +141 -0
  49. data/lib/fhirpath/models/r4/choiceTypePaths.json +1365 -0
  50. data/lib/fhirpath/models/r4/path2Type.json +7707 -0
  51. data/lib/fhirpath/models/r4/pathsDefinedElsewhere.json +57 -0
  52. data/lib/fhirpath/models/r4/type2Parent.json +211 -0
  53. data/lib/fhirpath/models/r5/choiceTypePaths.json +1883 -0
  54. data/lib/fhirpath/models/r5/path2Type.json +9653 -0
  55. data/lib/fhirpath/models/r5/pathsDefinedElsewhere.json +80 -0
  56. data/lib/fhirpath/models/r5/type2Parent.json +234 -0
  57. data/lib/fhirpath/models/stu3/choiceTypePaths.json +991 -0
  58. data/lib/fhirpath/models/stu3/path2Type.json +5729 -0
  59. data/lib/fhirpath/models/stu3/pathsDefinedElsewhere.json +37 -0
  60. data/lib/fhirpath/models/stu3/type2Parent.json +173 -0
  61. data/lib/fhirpath/models.rb +25 -0
  62. data/lib/fhirpath/parser/FHIRPath.g4 +172 -0
  63. data/lib/fhirpath/parser/ast_builder.rb +51 -0
  64. data/lib/fhirpath/parser.rb +21 -0
  65. data/lib/fhirpath/version.rb +5 -0
  66. data/lib/fhirpath.rb +117 -0
  67. data/rakelib/parser.rake +146 -0
  68. data/sig/fhirpath.rbs +14 -0
  69. metadata +168 -0
@@ -0,0 +1,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