fhirpath 0.2.0.pre1 → 0.2.0.pre2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0759fa82ecea31aef7e8fb5ffc0c8a1fc023d834ffe49eb296d706bf42ff7ed8'
4
- data.tar.gz: 0156774a1f73b8099001478f57704e8ee969738454f1019a09d398b9420aa64d
3
+ metadata.gz: 214b8bf3637ea378e16e6a60c8427b8ef7ba6e6fa25a8c9612d43ee4b4e710aa
4
+ data.tar.gz: e581ba72d78a0c59a90eac7c882ec1f2e1069415022ce029529e643c3158ecbb
5
5
  SHA512:
6
- metadata.gz: 0cd68f2b5a80fd409a066965422a067d1a0664a2bd6c65c215b82c19655a8064ec4da47a54bc4b6767d0f65f7f38ee997815d63796a9097d43df3efcfe6ad448
7
- data.tar.gz: 42a9413ad029f7a5e77bbfd2e796db4d37debd2152f3f9f1c3e28d4a74100027ef56abb6a63cd2a8bc383c83519546f6d18949f8db38a3dccf73d43aa7b4747b
6
+ metadata.gz: 29028eef4e40be4d55d69c5bebd874ae19cf606cef528d34296dc84960e00c5e5e99fa06ff8ebe2196250f857499d817511fcca71df70518dfbb06026721714b
7
+ data.tar.gz: 26e1734ac098eeaf716bca1955993da406b06652bf1ccf0b238e17d7ff8a015b2cd23b6bfbaf69dc420ca5c6663e8c055c47998676bf66bca014f2b8dce25952
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. The project is pre-1.0;
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0.pre2] - 2026-09-06
8
+ - Add the `ofType()` function to the standard registry. Filters the receiver
9
+ collection to items whose runtime type matches the specified type identifier
10
+ (e.g., `Integer`, `String`, `Decimal`, `Boolean`, `Date`, `DateTime`, `Time`),
11
+ or whose recorded model type matches an FHIR resource type (e.g.,
12
+ `Observation`, `Patient`). The new function is implemented against the
13
+ receiver's parallel `types` array (set during navigation via the
14
+ `ModelProvider#type_of` hook), so it works on both bare values and resources
15
+ produced by the FHIR R4 adapter. Closes part of issue #13.
16
+ - Record the model-resolved resource type on every navigation result so the
17
+ R4 adapter can be filtered by `ofType(ResourceName)` (e.g., filtering a
18
+ Bundle's `entry.resource` collection to `Observation` only). Previously the
19
+ evaluator recorded the choice-variant logical type only; the new fallback
20
+ covers non-choice properties and the resource's own `resourceType`.
21
+ - Add FHIRPath Date/Time/DateTime value types and temporal operations:
22
+ - Lexer supports the `@YYYY-MM-DD`, `@THH:MM:SS`, `@YYYY-MM-DDTHH:MM:SSZ`,
23
+ and `@YYYY-MM-DDTHH:MM:SS±HH:MM` temporal-literal syntax.
24
+ - Nullary functions: `today()` → Date, `now()` → DateTime, `time()` → Time.
25
+ - Component extractors on singletons: `year()`, `month()`, `day()`,
26
+ `hour()`, `minute()`, `second()`, `millisecond()`. `millisecond()` on
27
+ `DateTime` reads `sec_fraction * 1000` (the only fractional-precision
28
+ path Ruby exposes for DateTime).
29
+ - Timezone functions: `timezone()` returns the formatted offset string
30
+ (e.g., `+05:30`); `timezoneOffset()` returns the offset in minutes.
31
+ - Same-type temporal comparisons via `<`, `<=`, `=`, `>=`, `>` (date/date,
32
+ datetime/datetime, time/time only — cross-type compares raise
33
+ `incompatible_comparison`).
34
+ - `ofType(Date|DateTime|Time)` filtering.
35
+ - 26 new tests across literals, now/today/time, components, comparison,
36
+ ofType, and edge cases (empty input, singleton requirement). All pass.
37
+ - Add FHIR primitive-extension accessors (`._<name>`) per FHIRPath 2.0.0
38
+ spec. `<primitive>._<name>` returns the underlying `{value, extension}`
39
+ JSON container when the source primitive has a `value`, and empty when
40
+ only an extension is present. 4 new tests added. Closes part of issue #45.
41
+
7
42
  ## [0.2.0.pre1] - 2026-09-05
8
43
  - Add the `sum()`, `avg()`, `max()`, and `min()` aggregate functions to the
9
44
  standard registry. These are FHIRPath 3.0.0 STU3 aggregate additions
@@ -1,6 +1,6 @@
1
1
  # Feature and capability matrix
2
2
 
3
- Status: `0.2.0.pre1`; target release: FHIRPath `2.0.0`; publication contract: [`support-matrix.md`](support-matrix.md)
3
+ Status: `0.2.0.pre2`; target release: FHIRPath `2.0.0`; publication contract: [`support-matrix.md`](support-matrix.md)
4
4
 
5
5
  This matrix is deliberately conservative. `Supported` means the behavior is exercised by the Ruby test suite or the checked-in vector corpus. `Deferred` means callers should expect a structured unsupported/unknown error. `Host-dependent` requires an adapter or injected service that is not shipped here.
6
6
 
@@ -32,7 +32,14 @@ This matrix is deliberately conservative. `Supported` means the behavior is exer
32
32
  | Custom registered functions | Supported | API/foundation tests |
33
33
  | Compiled-expression reuse | Supported | API/foundation tests |
34
34
  | Stable structured engine errors | Supported | API/foundation/parser tests |
35
- | Date/time literals and values | Deferred | no temporal value implementation |
35
+ | `ofType()` type filter | Supported | `test/oftype_test.rb`; filters collections by built-in (`Integer`, `String`, `Decimal`, `Boolean`, `Date`, `DateTime`, `Time`) and FHIR resource types recorded during navigation; logical-type `is`/`as` previously deferred this slice |
36
+ | Date/Time/DateTime literals (`@...`) | Supported | `test/temporal_literals_test.rb`; ISO 8601 with optional timezone (`Z`/`±HH:MM`) |
37
+ | `today()`, `now()`, `time()` | Supported | `test/temporal_now_test.rb` |
38
+ | Temporal component extractors (`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`) | Supported | `test/temporal_components_test.rb`; `millisecond()` on `DateTime` reads `sec_fraction * 1000` |
39
+ | Temporal timezone (`timezone()`, `timezoneOffset()`) | Supported | `test/temporal_components_test.rb` |
40
+ | Temporal same-type comparison | Supported | `test/temporal_comparison_test.rb`; cross-type raises `incompatible_comparison` |
41
+ | Temporal arithmetic with Quantity/Duration | Deferred | no Quantity type yet |
42
+ | FHIR primitive extension accessor (`._<name>`) | Supported | `test/primitive_extensions_test.rb`; returns the underlying `{value, extension}` container or empty per FHIRPath 2.0.0 |
36
43
  | Quantity/UCUM | Deferred | no unit service or quantity implementation |
37
44
  | Advanced conversion/math/string/regex | Deferred | not in standard registry |
38
45
  | FHIR R4 model adapter (`model: :r4`) | Supported | `test/r4_model_test.rb`; dependency-free `FHIRPath::FHIR::R4::ModelProvider` |
@@ -1,6 +1,6 @@
1
1
  # Release support matrix
2
2
 
3
- Status: `pre-release` (`0.2.0.pre1`)
3
+ Status: `pre-release` (`0.2.0.pre2`)
4
4
 
5
5
  This is the release-facing support matrix. It is deliberately narrower than
6
6
  "FHIRPath-compatible": every supported claim must have executable evidence, and
@@ -38,7 +38,10 @@ These stable identifiers describe the behavior included in the current package:
38
38
  - `comparison-and-equivalence` — relational comparison, equality, and equivalence;
39
39
  - `boolean-logic` — empty-aware Boolean operators;
40
40
  - `union-membership-and-type-operators` — union, `in`, `contains`, `is`, and `as`;
41
- - `collection-functions` — `where`, `select`, `first`, `exists`, `count`, `empty`, `not`, `all`, and Boolean aggregates;
41
+ - `collection-functions` — `where`, `select`, `first`, `exists`, `count`, `empty`, `not`, `all`, Boolean aggregates, and `ofType`;
42
+ - `subsetting-functions` — `tail`, `take`, `skip`, `last`;
43
+ - `temporal-types` — `Date`, `DateTime`, `Time` literals (`@YYYY-MM-DD`, `@THH:MM:SS`, `@YYYY-MM-DDTHH:MM:SS±HH:MM`), `today()`, `now()`, `time()`, component extractors (`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`), `timezone()`, `timezoneOffset()`, and same-type comparison;
44
+ - `fhir-primitive-extensions` — `._<name>` accessor returning the underlying `{value, extension}` container or empty per FHIRPath 2.0.0;
42
45
  - `focus-variables` — `$this`, `$index`, and `$total`;
43
46
  - `external-constants` — explicitly supplied `%name` values;
44
47
  - `custom-functions` — explicitly registered functions;
@@ -100,10 +100,21 @@ module FHIRPath
100
100
 
101
101
  def navigate(node, context)
102
102
  receiver = evaluate(node.receiver, context)
103
+ return navigate_primitive_extension(receiver, node) if node.name.start_with?('_')
104
+
103
105
  values = []
104
106
  model_types = []
105
107
  receiver.items.each do |item|
106
- append_property(values, model_types, item, node.name, context)
108
+ produced = Collection.from(context.model.property(item, node.name)).items
109
+ type = context.model.property_logical_type(item, node.name)
110
+ values.concat(produced)
111
+ model_types.concat(Array.new(produced.length, type))
112
+ # If no choice type was recorded, try to get the resource type from the model
113
+ next unless type.nil? && context.model.respond_to?(:type_of)
114
+
115
+ produced.each do |produced_item|
116
+ model_types[-produced.length + produced.index(produced_item)] = context.model.type_of(produced_item)
117
+ end
107
118
  end
108
119
  Collection.new(values, types: model_types)
109
120
  rescue NoMethodError => e
@@ -111,6 +122,16 @@ module FHIRPath
111
122
  span: node.span, cause: e)
112
123
  end
113
124
 
125
+ # Handle the FHIR primitive-extension accessor (`_<name>`) on a primitive
126
+ # JSON element that is split into `{ value, extension }` per the FHIR spec.
127
+ # Per FHIRPath 2.0.0, `<primitive>._<name>` returns the underlying primitive
128
+ # container (the `{value, extension}` shape) when the source element already
129
+ # has a value, and empty when the primitive has only an extension.
130
+ def navigate_primitive_extension(receiver, _node)
131
+ values = receiver.items.select { |item| item.is_a?(Hash) && (item.key?('value') || item.key?(:value)) }
132
+ Collection.new(values)
133
+ end
134
+
114
135
  # Resolves one property and records, per produced item, the logical type
115
136
  # name the model declares for the resolved value (nil when the model has no
116
137
  # metadata for this property, e.g. PlainModel or a non-choice field).
@@ -192,6 +213,18 @@ module FHIRPath
192
213
  all(receiver, node.arguments.first, context)
193
214
  when 'allTrue', 'anyTrue', 'allFalse', 'anyFalse'
194
215
  boolean_aggregate(receiver, node.name, node)
216
+ when 'ofType'
217
+ of_type(receiver, node, context)
218
+ when 'today'
219
+ temporal_now(receiver, node, context, :date)
220
+ when 'now'
221
+ temporal_now(receiver, node, context, :datetime)
222
+ when 'time'
223
+ temporal_now(receiver, node, context, :time)
224
+ when 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond'
225
+ temporal_component(receiver, node, context, node.name)
226
+ when 'timezone', 'timezoneOffset'
227
+ temporal_timezone(receiver, node, context, node.name)
195
228
  else
196
229
  invoke_registered(node, receiver, context, spec)
197
230
  end
@@ -562,6 +595,101 @@ module FHIRPath
562
595
  Collection.new([left.items.any? { |candidate| equal?(candidate, value) }])
563
596
  end
564
597
 
598
+ def of_type(receiver, node, _context)
599
+ # FHIRPath ofType() filters the collection to items matching the specified type
600
+ # The argument is a type identifier (e.g., Integer, String, Decimal, Boolean,
601
+ # or FHIR resource types like Observation, Patient)
602
+ type_arg = node.arguments.first
603
+ unless type_arg.is_a?(AST::Identifier)
604
+ raise TypeError.new('ofType requires a type identifier', code: :expected_type, span: type_arg.span)
605
+ end
606
+
607
+ type_name = type_arg.name.downcase
608
+
609
+ # Use the receiver's recorded types (parallel to items) for model types,
610
+ # fall back to logical_type? for built-in types
611
+ matching_items = receiver.items.each_with_index.select do |item, index|
612
+ item_type = receiver.types&.at(index)
613
+ (item_type && item_type.downcase == type_name) || logical_type?(item, type_name)
614
+ end.map(&:first)
615
+ Collection.new(matching_items)
616
+ end
617
+
618
+ def temporal_now(receiver, _node, _context, type)
619
+ return Collection.empty if receiver.empty?
620
+
621
+ # today(), now(), time() are nullary functions - receiver is ignored
622
+ case type
623
+ when :date
624
+ Collection.new([Date.today])
625
+ when :datetime
626
+ Collection.new([DateTime.now])
627
+ when :time
628
+ Collection.new([Time.now])
629
+ end
630
+ end
631
+
632
+ def temporal_component(receiver, node, _context, component)
633
+ return Collection.empty if receiver.empty?
634
+
635
+ value = require_singleton(receiver, node.span)
636
+ result = case component
637
+ when 'year'
638
+ value.respond_to?(:year) ? value.year : nil
639
+ when 'month'
640
+ value.respond_to?(:month) ? value.month : nil
641
+ when 'day'
642
+ value.respond_to?(:day) ? value.day : nil
643
+ when 'hour'
644
+ value.respond_to?(:hour) ? value.hour : nil
645
+ when 'minute'
646
+ value.respond_to?(:min) ? value.min : nil
647
+ when 'second'
648
+ value.respond_to?(:sec) ? value.sec : nil
649
+ when 'millisecond'
650
+ if value.is_a?(DateTime)
651
+ # DateTime stores fractional seconds as a Rational
652
+ (value.sec_fraction * 1000).to_i
653
+ elsif value.respond_to?(:msec)
654
+ value.msec
655
+ elsif value.respond_to?(:nsec)
656
+ (value.nsec / 1_000_000).to_i
657
+ end
658
+ end
659
+ if result.nil?
660
+ raise TypeError.new("temporal component #{component} not supported on #{value.class}",
661
+ code: :unsupported_temporal, span: node.span)
662
+ end
663
+
664
+ Collection.new([result])
665
+ end
666
+
667
+ def temporal_timezone(receiver, node, _context, component)
668
+ return Collection.empty if receiver.empty?
669
+
670
+ value = require_singleton(receiver, node.span)
671
+ unless value.respond_to?(:zone) || value.respond_to?(:offset)
672
+ raise TypeError.new("timezone not supported on #{value.class}", code: :unsupported_temporal, span: node.span)
673
+ end
674
+
675
+ result = if component == 'timezone'
676
+ value.respond_to?(:zone) ? value.zone : format_offset(value.offset)
677
+ else
678
+ # timezoneOffset returns minutes
679
+ value.respond_to?(:offset) ? (value.offset * 24 * 60).to_i : nil
680
+ end
681
+ Collection.new([result])
682
+ end
683
+
684
+ def format_offset(offset)
685
+ return '+00:00' if offset.nil? || offset.zero?
686
+
687
+ hours = (offset * 24).to_i
688
+ minutes = ((offset * 24 * 60) % 60).to_i
689
+ sign = hours >= 0 ? '+' : '-'
690
+ format('%s%02d:%02d', sign, hours.abs, minutes.abs)
691
+ end
692
+
565
693
  def type_operator(node, context)
566
694
  left = evaluate(node.left, context)
567
695
  type_name = type_name_from(node.right, node.span)
@@ -597,6 +725,9 @@ module FHIRPath
597
725
  when 'decimal' then value.is_a?(BigDecimal) || (value.is_a?(::Float) && value.finite?)
598
726
  when 'number' then numeric?(value)
599
727
  when 'string' then value.is_a?(::String)
728
+ when 'date' then value.is_a?(Date)
729
+ when 'datetime' then value.is_a?(DateTime)
730
+ when 'time' then value.is_a?(Time)
600
731
  else false
601
732
  end
602
733
  end
@@ -604,7 +735,10 @@ module FHIRPath
604
735
  def compare_values(left, right, span)
605
736
  if numeric?(left) && numeric?(right)
606
737
  decimal(left) <=> decimal(right)
607
- elsif left.is_a?(::String) && right.is_a?(::String)
738
+ elsif (left.is_a?(::String) && right.is_a?(::String)) ||
739
+ (left.is_a?(Date) && right.is_a?(Date)) ||
740
+ (left.is_a?(DateTime) && right.is_a?(DateTime)) ||
741
+ (left.is_a?(Time) && right.is_a?(Time))
608
742
  left <=> right
609
743
  else
610
744
  raise TypeError.new('comparison requires compatible values', code: :incompatible_comparison,
@@ -13,10 +13,13 @@ module FHIRPath
13
13
 
14
14
  class FunctionRegistry
15
15
  STANDARD_NAMES = %w[
16
- where select first last tail take skip
16
+ where select first last tail take skip ofType
17
17
  exists count empty not all
18
18
  allTrue anyTrue allFalse anyFalse
19
19
  sum avg max min
20
+ today now time
21
+ year month day hour minute second millisecond
22
+ timezone timezoneOffset
20
23
  ].freeze
21
24
  DELAYED_NAMES = %w[where select exists all].freeze
22
25
 
@@ -38,7 +41,7 @@ module FHIRPath
38
41
 
39
42
  def self.standard_arity(name)
40
43
  return 0..1 if name == 'exists'
41
- return 1 if name == 'all' || %w[where select take skip].include?(name)
44
+ return 1 if name == 'all' || %w[where select take skip ofType].include?(name)
42
45
 
43
46
  0
44
47
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bigdecimal'
4
+ require 'date'
5
+ require 'time'
4
6
 
5
7
  module FHIRPath
6
8
  class Token
@@ -22,7 +24,8 @@ module FHIRPath
22
24
  '=' => :equals, '+' => :plus, '-' => :minus,
23
25
  '*' => :multiply, '/' => :divide,
24
26
  '<' => :less_than, '>' => :greater_than,
25
- '|' => :union, '&' => :concatenate, '~' => :equivalent
27
+ '|' => :union, '&' => :concatenate, '~' => :equivalent,
28
+ '@' => :temporal
26
29
  }.freeze
27
30
 
28
31
  TWO_CHARACTER = {
@@ -85,6 +88,8 @@ module FHIRPath
85
88
 
86
89
  char = @source[@index]
87
90
  if SINGLE.key?(char)
91
+ return temporal_token(start) if char == '@'
92
+
88
93
  @index += 1
89
94
  return token(SINGLE.fetch(char), char, start)
90
95
  end
@@ -210,6 +215,98 @@ module FHIRPath
210
215
  token(:identifier, text, start)
211
216
  end
212
217
 
218
+ def temporal_token(start)
219
+ @index += 1 # skip '@'
220
+ temporal_start = @index
221
+
222
+ has_date = parse_date_part?
223
+ has_time = parse_time_part?
224
+ parse_timezone_part
225
+
226
+ text = @source[temporal_start...@index]
227
+ raise_error('empty temporal literal', start) if text.empty?
228
+
229
+ value, type = parse_temporal_value(text, has_date, has_time)
230
+ token(type, value, start)
231
+ rescue ArgumentError => e
232
+ raise_error("invalid temporal literal: #{e.message}", start, code: :invalid_temporal)
233
+ end
234
+
235
+ def parse_date_part?
236
+ return false if eof? || !@source[@index].match?(/[0-9]/)
237
+
238
+ @index += 4 # YYYY
239
+ return false if eof? || @source[@index] != '-'
240
+
241
+ @index += 1
242
+ @index += 2 # MM
243
+ return false if eof? || @source[@index] != '-'
244
+
245
+ @index += 1
246
+ @index += 2 # DD
247
+ true
248
+ end
249
+
250
+ def parse_time_part?
251
+ return false if eof? || @source[@index] != 'T'
252
+
253
+ @index += 1
254
+ return false if eof? || !@source[@index, 2].match?(/^\d{2}$/)
255
+
256
+ @index += 2 # HH
257
+ return false if eof? || @source[@index] != ':'
258
+
259
+ @index += 1
260
+ return false if eof? || !@source[@index, 2].match?(/^\d{2}$/)
261
+
262
+ @index += 2 # MM
263
+ return false if eof? || @source[@index] != ':'
264
+
265
+ @index += 1
266
+ return false if eof? || !@source[@index, 2].match?(/^\d{2}$/)
267
+
268
+ @index += 2 # SS
269
+
270
+ # Optional milliseconds
271
+ if !eof? && @source[@index] == '.'
272
+ @index += 1
273
+ @index += 1 while !eof? && @source[@index].match?(/[0-9]/)
274
+ end
275
+ true
276
+ end
277
+
278
+ def parse_timezone_part
279
+ return if eof? || !@source[@index].match?(/[Z+-]/)
280
+
281
+ if @source[@index] == 'Z'
282
+ @index += 1
283
+ else
284
+ @index += 1 # + or -
285
+ @index += 2 # HH
286
+ @index += 1 if !eof? && @source[@index] == ':'
287
+ @index += 2
288
+ end
289
+ end
290
+
291
+ def parse_temporal_value(text, has_date, has_time)
292
+ if has_date && has_time
293
+ [DateTime.parse(text), :datetime]
294
+ elsif has_date
295
+ [Date.parse(text), :date]
296
+ elsif has_time
297
+ time_text = text
298
+ time_text = time_text[1..] if time_text.start_with?('T')
299
+ value = if time_text.include?('Z') || time_text.match?(/[+-]\d{2}:?\d{2}$/)
300
+ DateTime.parse("2000-01-01T#{time_text}").to_time
301
+ else
302
+ Time.parse("2000-01-01T#{time_text}")
303
+ end
304
+ [value, :time]
305
+ else
306
+ raise ArgumentError, 'invalid temporal literal'
307
+ end
308
+ end
309
+
213
310
  def token(type, value, start)
214
311
  Token.new(type: type, value: value,
215
312
  span: SourceSpan.new(offset: start, length: @index - start))
@@ -341,7 +438,7 @@ module FHIRPath
341
438
  def parse_primary
342
439
  token = advance
343
440
  node = case token.type
344
- when :string, :integer, :decimal
441
+ when :string, :integer, :decimal, :date, :time, :datetime
345
442
  AST::Literal.new(value: token.value, span: token.span)
346
443
  when :identifier
347
444
  if %w[true false].include?(token.value)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FHIRPath
4
- VERSION = '0.2.0.pre1'
4
+ VERSION = '0.2.0.pre2'
5
5
  # A stable release requires the complete release gate to be deliberately
6
6
  # promoted. Keep pre-release status explicit while the shared-suite and
7
7
  # model-adapter work remains incomplete.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fhirpath
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.pre1
4
+ version: 0.2.0.pre2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicco Reyes
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal