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 +4 -4
- data/CHANGELOG.md +35 -0
- data/docs/feature-matrix.md +9 -2
- data/docs/support-matrix.md +5 -2
- data/lib/fhirpath/evaluator.rb +136 -2
- data/lib/fhirpath/functions.rb +5 -2
- data/lib/fhirpath/parser.rb +99 -2
- data/lib/fhirpath/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 214b8bf3637ea378e16e6a60c8427b8ef7ba6e6fa25a8c9612d43ee4b4e710aa
|
|
4
|
+
data.tar.gz: e581ba72d78a0c59a90eac7c882ec1f2e1069415022ce029529e643c3158ecbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/feature-matrix.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Feature and capability matrix
|
|
2
2
|
|
|
3
|
-
Status: `0.2.0.
|
|
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
|
-
|
|
|
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` |
|
data/docs/support-matrix.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Release support matrix
|
|
2
2
|
|
|
3
|
-
Status: `pre-release` (`0.2.0.
|
|
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`,
|
|
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;
|
data/lib/fhirpath/evaluator.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
data/lib/fhirpath/functions.rb
CHANGED
|
@@ -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
|
data/lib/fhirpath/parser.rb
CHANGED
|
@@ -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)
|
data/lib/fhirpath/version.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FHIRPath
|
|
4
|
-
VERSION = '0.2.0.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|