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