fhirpath 0.2.0.pre1

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.
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rexml/document'
5
+ require 'yaml'
6
+ require_relative '../capability'
7
+
8
+ module FHIRPath
9
+ module Conformance
10
+ # Imports a selected, pinned subset of the official XML shared suite.
11
+ # The importer is deliberately Ruby-only so it is safe to use in tests and
12
+ # release checks without installing a second language runtime.
13
+ class Importer
14
+ DEFAULT_SUITE = 'FHIR/fhir-test-cases'
15
+
16
+ def self.from_manifest(path, source_root: nil)
17
+ manifest = JSON.parse(File.read(path))
18
+ root = source_root || File.dirname(path)
19
+ new(
20
+ source_root: root,
21
+ suite_path: manifest.fetch('source'),
22
+ fixture_root: manifest['fixture_root'],
23
+ suite: manifest.fetch('suite', DEFAULT_SUITE),
24
+ suite_commit: manifest.fetch('suite_commit'),
25
+ target: manifest.fetch('target'),
26
+ model: manifest.fetch('model', 'plain'),
27
+ host_features: manifest.fetch('host_features', Capability.current.host_features),
28
+ case_ids: manifest['cases']
29
+ )
30
+ end
31
+
32
+ def initialize(source_root:, suite_path:, suite_commit:, suite: DEFAULT_SUITE, target: '2.0.0',
33
+ fixture_root: nil, model: 'plain', host_features: Capability.current.host_features, case_ids: nil)
34
+ @source_root = File.expand_path(source_root)
35
+ @suite_path = relative_path(suite_path)
36
+ @fixture_root = fixture_root && relative_path(fixture_root)
37
+ @suite = suite.to_s.freeze
38
+ @suite_commit = suite_commit.to_s.freeze
39
+ @target = target.to_s.freeze
40
+ @model = model.to_s.freeze
41
+ @host_features = Array(host_features).map(&:to_s).freeze
42
+ @case_ids = case_ids&.map(&:to_s)&.freeze
43
+ validate_options
44
+ end
45
+
46
+ def import
47
+ case File.extname(@suite_path).downcase
48
+ when '.xml' then import_xml
49
+ when '.yaml', '.yml' then import_yaml
50
+ else raise ArgumentError, "unsupported suite format: #{@suite_path}"
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def validate_options
57
+ raise ArgumentError, 'suite_commit must not be empty' if @suite_commit.empty?
58
+ raise ArgumentError, 'target must not be empty' if @target.empty?
59
+ end
60
+
61
+ def import_xml
62
+ document = REXML::Document.new(File.read(absolute_path(@suite_path)))
63
+ tests = document.root.elements.to_a('group/test')
64
+ select_tests(tests).map { |test| record_for(test) }
65
+ end
66
+
67
+ def import_yaml
68
+ document = YAML.safe_load_file(absolute_path(@suite_path), permitted_classes: [], aliases: false)
69
+ subject = deep_copy(document['subject']) if document.is_a?(Hash)
70
+ ordinal = 0
71
+ records = []
72
+ flatten_yaml_tests(document.fetch('tests')).each do |test, group, disabled|
73
+ expressions = test['expression'].is_a?(Array) ? test['expression'] : [test['expression']]
74
+ expressions.each do |expression|
75
+ ordinal += 1
76
+ records << yaml_record_for(test, expression, subject, group, disabled, ordinal)
77
+ end
78
+ end
79
+ records
80
+ end
81
+
82
+ def flatten_yaml_tests(value, group = nil, disabled: false, records: [])
83
+ Array(value).each { |entry| flatten_yaml_entry(entry, group, disabled, records) }
84
+ records
85
+ end
86
+
87
+ def flatten_yaml_entry(entry, group, disabled, records)
88
+ raise ArgumentError, 'YAML suite entries must be mappings' unless entry.is_a?(Hash)
89
+
90
+ group_key = entry.keys.find { |key| key.to_s.start_with?('group') }
91
+ if group_key
92
+ group_name = group_key.to_s.delete_prefix('group:').strip
93
+ group_disabled = disabled || entry['disable'] == true
94
+ flatten_yaml_tests(entry.fetch(group_key), group_name, disabled: group_disabled, records: records)
95
+ elsif entry.key?('expression')
96
+ records << [entry, group, disabled || entry['disable'] == true]
97
+ end
98
+ end
99
+
100
+ def select_tests(tests)
101
+ return tests unless @case_ids
102
+
103
+ by_id = tests.to_h { |test| [test.attributes.fetch('name').to_s, test] }
104
+ @case_ids.map do |case_id|
105
+ by_id.fetch(case_id) { raise ArgumentError, "case not found in suite: #{case_id}" }
106
+ end
107
+ end
108
+
109
+ def record_for(test)
110
+ case_id = test.attributes.fetch('name').to_s
111
+ input_fixture = test.attributes['inputfile']&.to_s
112
+ resource, fixture_metadata = fixture_for(input_fixture)
113
+ record = build_record(
114
+ id: case_id,
115
+ expression: test.elements['expression']&.text.to_s.strip,
116
+ input_fixture: input_fixture,
117
+ model: @model,
118
+ capability: test.parent.attributes['name'].to_s,
119
+ expected: expected_outputs(test),
120
+ resource: resource,
121
+ variables: {},
122
+ origin: origin_for(case_id)
123
+ ).merge(fixture_metadata)
124
+ add_field(record, 'error', parse_error(test))
125
+ finalize_record(record, fixture_metadata, disabled_test?(test))
126
+ end
127
+
128
+ def yaml_record_for(test, expression, subject, group, disabled, ordinal)
129
+ input_fixture = test['inputfile']&.to_s
130
+ resource, fixture_metadata = fixture_for(input_fixture, subject)
131
+ case_id = "#{@suite_path}:#{ordinal}"
132
+ record = build_record(
133
+ id: case_id,
134
+ expression: expression.to_s,
135
+ input_fixture: input_fixture,
136
+ model: (test['model'] || @model).to_s,
137
+ capability: group || 'unspecified',
138
+ expected: yaml_expected(test),
139
+ resource: resource,
140
+ variables: deep_copy(test['variables'] || {}),
141
+ origin: origin_for(test['desc'] || case_id)
142
+ ).merge(fixture_metadata)
143
+ add_yaml_details(record, test)
144
+ finalize_record(record, fixture_metadata, disabled)
145
+ end
146
+
147
+ def add_yaml_details(record, test)
148
+ add_field(record, 'description', test['desc']&.to_s)
149
+ add_field(record, 'context', test['context']&.to_s)
150
+ add_true_field(record, 'error', test['error'] == true)
151
+ end
152
+
153
+ def build_record(id:, expression:, input_fixture:, model:, capability:, expected:, resource:, variables:, origin:)
154
+ {
155
+ 'id' => id,
156
+ 'suite' => @suite,
157
+ 'suite_commit' => @suite_commit,
158
+ 'expression' => expression,
159
+ 'input_fixture' => normalized_fixture_path(input_fixture),
160
+ 'model' => model,
161
+ 'host_features' => @host_features,
162
+ 'target' => @target,
163
+ 'capability' => capability,
164
+ 'expected' => expected,
165
+ 'resource' => resource,
166
+ 'variables' => variables,
167
+ 'origin' => origin
168
+ }
169
+ end
170
+
171
+ def expected_outputs(test)
172
+ test.elements.to_a('output').map { |output| parse_output(output) }
173
+ end
174
+
175
+ def yaml_expected(test)
176
+ test.key?('result') ? normalize_yaml_result(test['result']) : []
177
+ end
178
+
179
+ def origin_for(case_id)
180
+ {
181
+ 'suite' => @suite,
182
+ 'suite_commit' => @suite_commit,
183
+ 'case' => case_id,
184
+ 'path' => @suite_path
185
+ }
186
+ end
187
+
188
+ def add_field(record, key, value)
189
+ record[key] = value unless value.nil?
190
+ record
191
+ end
192
+
193
+ def add_true_field(record, key, condition)
194
+ record[key] = true if condition
195
+ record
196
+ end
197
+
198
+ def finalize_record(record, fixture_metadata, disabled)
199
+ mark_not_run(record, fixture_metadata['not_run_reason']) if fixture_metadata['not_run_reason']
200
+ mark_not_run(record, 'disabled by source suite') if disabled
201
+ record
202
+ end
203
+
204
+ def normalize_yaml_result(result)
205
+ result.is_a?(Array) ? deep_copy(result) : [deep_copy(result)]
206
+ end
207
+
208
+ def disabled_test?(test)
209
+ test.attributes['disabled'].to_s == 'true' || test.attributes['disable'].to_s == 'true'
210
+ end
211
+
212
+ def mark_not_run(record, reason)
213
+ record['classification'] = 'not-run'
214
+ record['not_run_reason'] = reason
215
+ record['resource'] = nil if reason.include?('matching JSON fixture')
216
+ end
217
+
218
+ def parse_error(test)
219
+ error = test.elements['error']
220
+ return unless error
221
+
222
+ data = {}
223
+ %w[class code].each do |key|
224
+ data[key] = error.attributes[key].to_s if error.attributes[key]
225
+ end
226
+ data['class'] ||= error.text.to_s.strip unless error.text.to_s.strip.empty?
227
+ data.empty? ? nil : data
228
+ end
229
+
230
+ def parse_output(output)
231
+ value = output.text.to_s
232
+ case output.attributes['type'].to_s
233
+ when 'boolean' then value.strip.casecmp('true').zero?
234
+ when 'integer' then Integer(value.strip, 10)
235
+ when 'decimal' then value.strip
236
+ when 'string', 'code', 'id', 'uri', 'url', 'markdown' then value
237
+ else
238
+ { '$type' => output.attributes['type'], 'value' => value }
239
+ end
240
+ end
241
+
242
+ def fixture_for(input_fixture, fallback = {})
243
+ return [deep_copy(fallback || {}), {}] unless input_fixture
244
+
245
+ read_fixture(input_fixture)
246
+ end
247
+
248
+ def normalized_fixture_path(input_fixture)
249
+ input_fixture && fixture_path(input_fixture)
250
+ end
251
+
252
+ def read_fixture(input_fixture)
253
+ path = absolute_path(fixture_path(input_fixture))
254
+ case File.extname(path).downcase
255
+ when '.json'
256
+ [JSON.parse(File.read(path)), { 'fixture_source' => fixture_path(input_fixture) }]
257
+ when '.xml'
258
+ json_path = matching_json_fixture(path)
259
+ unless json_path
260
+ return [nil, {
261
+ 'not_run_reason' => "no verified matching JSON fixture for #{fixture_path(input_fixture)}"
262
+ }]
263
+ end
264
+
265
+ [JSON.parse(File.read(json_path)), { 'fixture_source' => relative_path(json_path) }]
266
+ else
267
+ raise ArgumentError, "unsupported fixture format: #{input_fixture}"
268
+ end
269
+ end
270
+
271
+ def matching_json_fixture(xml_path)
272
+ candidates = [xml_path.sub(/\.xml\z/i, '.json')]
273
+ basename = File.basename(xml_path, File.extname(xml_path))
274
+ search_root = absolute_path(@fixture_root || File.dirname(@suite_path))
275
+ candidates.concat(Dir.glob(File.join(search_root, '**', "#{basename}.json")))
276
+ candidates.uniq.select { |candidate| File.file?(candidate) }.sort.find do |candidate|
277
+ resource = JSON.parse(File.read(candidate))
278
+ resource.is_a?(Hash) && resource['resourceType'].to_s == xml_resource_type(xml_path)
279
+ rescue JSON::ParserError
280
+ false
281
+ end
282
+ end
283
+
284
+ def xml_resource_type(path)
285
+ REXML::Document.new(File.read(path)).root.name.to_s.split(':').last
286
+ end
287
+
288
+ def deep_copy(value)
289
+ case value
290
+ when Hash
291
+ value.each_with_object({}) { |(key, item), copy| copy[deep_copy(key)] = deep_copy(item) }
292
+ when Array
293
+ value.map { |item| deep_copy(item) }
294
+ else
295
+ value
296
+ end
297
+ end
298
+
299
+ def fixture_path(input_fixture)
300
+ relative_path(File.join(@fixture_root || File.dirname(@suite_path), input_fixture))
301
+ end
302
+
303
+ def relative_path(path)
304
+ expanded = File.expand_path(path.to_s, @source_root)
305
+ prefix = "#{@source_root}#{File::SEPARATOR}"
306
+ return expanded.delete_prefix(prefix) if expanded.start_with?(prefix)
307
+ return '.' if expanded == @source_root
308
+
309
+ raise ArgumentError, "path escapes source root: #{path}"
310
+ end
311
+
312
+ def absolute_path(path)
313
+ File.join(@source_root, path)
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FHIRPath
4
+ class Error < StandardError
5
+ attr_reader :code, :span, :expression, :original_cause
6
+
7
+ def initialize(message = nil, code:, span: nil, expression: nil, cause: nil)
8
+ super(message)
9
+ @code = code.to_sym
10
+ @span = span
11
+ @expression = expression
12
+ @original_cause = cause
13
+ end
14
+
15
+ def to_h
16
+ data = { code: code, message: message }
17
+ data[:span] = span.to_h if span
18
+ data[:expression] = expression if expression
19
+ data
20
+ end
21
+ end
22
+
23
+ class ParseError < Error
24
+ def initialize(message = 'invalid expression', code: :parse_error, **kwargs)
25
+ super(message, code: code, **kwargs)
26
+ end
27
+ end
28
+
29
+ class EvaluationError < Error
30
+ def initialize(message = 'expression could not be evaluated', code: :evaluation_error, **kwargs)
31
+ super(message, code: code, **kwargs)
32
+ end
33
+ end
34
+
35
+ class SingletonError < EvaluationError
36
+ def initialize(message = 'a singleton value was required', code: :singleton_required, **kwargs)
37
+ super(message, code: code, **kwargs)
38
+ end
39
+ end
40
+
41
+ class TypeError < EvaluationError
42
+ def initialize(message = 'value has an incompatible type', code: :type_error, **kwargs)
43
+ super(message, code: code, **kwargs)
44
+ end
45
+ end
46
+
47
+ class UnknownFunctionError < EvaluationError
48
+ def initialize(message = 'unknown function', code: :unknown_function, **kwargs)
49
+ super(message, code: code, **kwargs)
50
+ end
51
+ end
52
+
53
+ class UnknownConstantError < EvaluationError
54
+ def initialize(message = 'unknown external constant', code: :unknown_constant, **kwargs)
55
+ super(message, code: code, **kwargs)
56
+ end
57
+ end
58
+
59
+ class ModelError < EvaluationError
60
+ def initialize(message = 'model navigation failed', code: :model_error, **kwargs)
61
+ super(message, code: code, **kwargs)
62
+ end
63
+ end
64
+
65
+ class HostError < EvaluationError
66
+ def initialize(message = 'host service failed', code: :host_error, **kwargs)
67
+ super(message, code: code, **kwargs)
68
+ end
69
+ end
70
+
71
+ class UnsupportedFeatureError < EvaluationError
72
+ def initialize(message = 'feature is not supported', code: :unsupported_feature, **kwargs)
73
+ super(message, code: code, **kwargs)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FHIRPath
4
+ # Per-call state. Compiled expressions never retain a context or resource.
5
+ class EvaluationContext
6
+ attr_reader :root, :focus, :variables, :model, :host, :functions, :capability,
7
+ :options, :index, :total
8
+
9
+ def initialize(root:, focus: nil, variables: {}, model: nil, host: nil,
10
+ functions: nil, capability: nil, options: {}, index: nil, total: nil)
11
+ @root = Collection.from(root)
12
+ @focus = Collection.from(focus || root)
13
+ @variables = variables.transform_keys(&:to_s).freeze
14
+ @model = model || PlainModel.new
15
+ @host = host || HostServices.default
16
+ @functions = functions || FunctionRegistry.standard
17
+ @capability = capability || Capability.current
18
+ @options = options.dup.freeze
19
+ @index = index
20
+ @total = total
21
+ freeze
22
+ end
23
+
24
+ def derive(focus:, variables: self.variables, index: nil, total: nil)
25
+ self.class.new(root: root, focus: focus, variables: variables, model: model,
26
+ host: host, functions: functions, capability: capability,
27
+ options: options, index: index, total: total)
28
+ end
29
+ end
30
+ end