inferno_core 0.6.1 → 0.6.2

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,379 @@
1
+ require_relative '../../fhir_resource_navigation'
2
+ require_relative '../../must_support_metadata_extractor'
3
+ require_relative '../profile_conformance_helper'
4
+
5
+ module Inferno
6
+ module DSL
7
+ module FHIREvaluation
8
+ module Rules
9
+ # AllMustSupportsPresent checks that at least one instance of every MustSupport element
10
+ # defined in the given profiles is populated in the given data.
11
+ # MustSupport elements include plain elements, extensions, and slices.
12
+ # The basis of the test is metadata generated in a first pass that processes the profile into a list of fields,
13
+ # then the second pass check that all elements in the list are present.
14
+ # This metadata approach allows for customizing what is checked, for example elements may be added or removed,
15
+ # or choices may be defined where only one choice of multiple must be populated to demonstrate support.
16
+ class AllMustSupportsPresent < Rule
17
+ include FHIRResourceNavigation
18
+ include ProfileConformanceHelper
19
+ attr_accessor :metadata
20
+
21
+ # check is invoked from the evaluator CLI and applies the logic for this Rule to the provided data.
22
+ # At least one instance of every MustSupport element defined in the profiles must be populated in the data.
23
+ # Findings from the rule will be added to context.results.
24
+ # The logic is configurable with a few options, but this method does not support customizing the metadata.
25
+ # @param context [Inferno::DSL::FHIREvaluation::EvaluationContext]
26
+ # @return [void]
27
+ def check(context)
28
+ missing_items_by_profile = {}
29
+ context.ig.profiles.each do |profile|
30
+ resources = pick_resources_for_profile(profile, context)
31
+ if resources.blank?
32
+ missing_items_by_profile[profile.url] = ['No matching resources were found to check']
33
+ next
34
+ end
35
+ requirement_extension = context.config.data['Rule']['AllMustSupportsPresent']['RequirementExtensionUrl']
36
+ debug_metadata = context.config.data['Rule']['AllMustSupportsPresent']['WriteMetadataForDebugging']
37
+ profile_metadata = extract_metadata(profile, context.ig, requirement_extension:)
38
+ missing_items = perform_must_support_test_with_metadata(resources, profile_metadata, debug_metadata:)
39
+
40
+ missing_items_by_profile[profile.url] = missing_items if missing_items.any?
41
+ end
42
+
43
+ if missing_items_by_profile.count.zero?
44
+ result = EvaluationResult.new('All MustSupports are present', severity: 'success', rule: self)
45
+ else
46
+ message = 'Found Profiles with not all MustSupports represented:'
47
+ missing_items_by_profile.each do |profile_url, missing_items|
48
+ message += "\n\t\t#{profile_url}: #{missing_items.join(', ')}"
49
+ end
50
+ result = EvaluationResult.new(message, rule: self)
51
+ end
52
+ context.add_result result
53
+ end
54
+
55
+ def pick_resources_for_profile(profile, context)
56
+ conformance_options = context.config.data['Rule']['AllMustSupportsPresent']['ConformanceOptions'].to_options
57
+
58
+ # Unless specifically looking for Bundles, break them out into the resources they include
59
+ all_resources =
60
+ if profile.type == 'Bundle'
61
+ context.data
62
+ else
63
+ flatten_bundles(context.data)
64
+ end
65
+
66
+ all_resources.filter do |r|
67
+ conforms_to_profile?(r, profile, conformance_options, context.validator)
68
+ end
69
+ end
70
+
71
+ # perform_must_support_test is invoked from DSL assertions, allows customizing the metadata with a block.
72
+ # Customizing the metadata may add, modify, or remove items.
73
+ # For instance, US Core 3.1.1 Patient "Previous Name" is defined as MS only in narrative.
74
+ # Choices are also defined only in narrative.
75
+ # @param profile [FHIR::StructureDefinition]
76
+ # @param resources [Array<FHIR::Model>]
77
+ # @param ig [Inferno::Entities::IG]
78
+ # @param debug_metadata [Boolean] if true, write out the final metadata used to a temporary file
79
+ # @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
80
+ # @yield [Metadata] Customize the metadata before running the test
81
+ # @return [Array<String>] list of elements that were not found in the provided resources
82
+ def perform_must_support_test(profile, resources, ig, debug_metadata: false, requirement_extension: nil)
83
+ profile_metadata = extract_metadata(profile, ig, requirement_extension:)
84
+ yield profile_metadata if block_given?
85
+
86
+ perform_must_support_test_with_metadata(resources, profile_metadata, debug_metadata:)
87
+ end
88
+
89
+ # perform_must_support_test_with_metadata is invoked from check and perform_must_support_test,
90
+ # with the metadata to be used as the basis for the test.
91
+ # It may also be invoked directly from a test if you want to completely overwrite the metadata.
92
+ # @param resources [Array<FHIR::Model>]
93
+ # @param profile_metadata [Metadata] Metadata object with must_supports field
94
+ # @param debug_metadata [Boolean] if true, write out the final metadata used to a temporary file
95
+ # @return [Array<String>] list of elements that were not found in the provided resources
96
+ def perform_must_support_test_with_metadata(resources, profile_metadata, debug_metadata: false)
97
+ return if resources.blank?
98
+
99
+ @metadata = profile_metadata
100
+
101
+ write_metadata_for_debugging if debug_metadata
102
+
103
+ missing_elements(resources)
104
+ missing_slices(resources)
105
+ missing_extensions(resources)
106
+
107
+ handle_must_support_choices if metadata.must_supports[:choices].present?
108
+
109
+ missing_must_support_strings
110
+ end
111
+
112
+ def extract_metadata(profile, ig, requirement_extension: nil)
113
+ MustSupportMetadataExtractor.new(profile.snapshot.element, profile, profile.type, ig, requirement_extension)
114
+ end
115
+
116
+ def write_metadata_for_debugging
117
+ outfile = "#{metadata.profile&.id}-#{SecureRandom.uuid}.yml"
118
+
119
+ File.open(File.join(Dir.tmpdir, outfile), 'w') do |f|
120
+ writable_metadata = { must_supports: @metadata.must_supports.to_hash }
121
+ f.write(YAML.dump(writable_metadata))
122
+ puts "Wrote MustSupport metadata to #{f.path}"
123
+ end
124
+ end
125
+
126
+ def handle_must_support_choices
127
+ handle_must_support_element_choices
128
+ handle_must_support_extension_choices
129
+ handle_must_support_slice_choices
130
+ end
131
+
132
+ def handle_must_support_element_choices
133
+ missing_elements.delete_if do |element|
134
+ choices = metadata.must_supports[:choices].find do |choice|
135
+ choice[:paths]&.include?(element[:path]) ||
136
+ choice[:elements]&.any? { |ms_element| ms_element[:path] == element[:path] }
137
+ end
138
+ any_choice_supported?(choices)
139
+ end
140
+ end
141
+
142
+ def handle_must_support_extension_choices
143
+ missing_extensions.delete_if do |extension|
144
+ choices = metadata.must_supports[:choices].find do |choice|
145
+ choice[:extension_ids]&.include?(extension[:id])
146
+ end
147
+ any_choice_supported?(choices)
148
+ end
149
+ end
150
+
151
+ def handle_must_support_slice_choices
152
+ missing_slices.delete_if do |slice|
153
+ choices = metadata.must_supports[:choices].find { |choice| choice[:slice_names]&.include?(slice[:name]) }
154
+ any_choice_supported?(choices)
155
+ end
156
+ end
157
+
158
+ def any_choice_supported?(choices)
159
+ return false unless choices.present?
160
+
161
+ any_path_choice_supported?(choices) ||
162
+ any_extension_ids_choice_supported?(choices) ||
163
+ any_slice_names_choice_supported?(choices) ||
164
+ any_elements_choice_supported?(choices)
165
+ end
166
+
167
+ def any_path_choice_supported?(choices)
168
+ return false unless choices[:paths].present?
169
+
170
+ choices[:paths].any? { |path| missing_elements.none? { |element| element[:path] == path } }
171
+ end
172
+
173
+ def any_extension_ids_choice_supported?(choices)
174
+ return false unless choices[:extension_ids].present?
175
+
176
+ choices[:extension_ids].any? do |extension_id|
177
+ missing_extensions.none? { |extension| extension[:id] == extension_id }
178
+ end
179
+ end
180
+
181
+ def any_slice_names_choice_supported?(choices)
182
+ return false unless choices[:slice_names].present?
183
+
184
+ choices[:slice_names].any? { |slice_name| missing_slices.none? { |slice| slice[:name] == slice_name } }
185
+ end
186
+
187
+ def any_elements_choice_supported?(choices)
188
+ return false unless choices[:elements].present?
189
+
190
+ choices[:elements].any? do |choice|
191
+ missing_elements.none? do |element|
192
+ element[:path] == choice[:path] && element[:fixed_value] == choice[:fixed_value]
193
+ end
194
+ end
195
+ end
196
+
197
+ def missing_must_support_strings
198
+ missing_elements.map { |element_definition| missing_element_string(element_definition) } +
199
+ missing_slices.map { |slice_definition| slice_definition[:slice_id] } +
200
+ missing_extensions.map { |extension_definition| extension_definition[:id] }
201
+ end
202
+
203
+ def missing_element_string(element_definition)
204
+ if element_definition[:fixed_value].present?
205
+ "#{element_definition[:path]}:#{element_definition[:fixed_value]}"
206
+ else
207
+ element_definition[:path]
208
+ end
209
+ end
210
+
211
+ def must_support_extensions
212
+ metadata.must_supports[:extensions]
213
+ end
214
+
215
+ def missing_extensions(resources = [])
216
+ @missing_extensions ||=
217
+ must_support_extensions.select do |extension_definition|
218
+ resources.none? do |resource|
219
+ path = extension_definition[:path]
220
+
221
+ if path == 'extension'
222
+ resource.extension.any? { |extension| extension.url == extension_definition[:url] }
223
+ else
224
+ extension = find_a_value_at(resource, path) do |el|
225
+ el.url == extension_definition[:url]
226
+ end
227
+
228
+ extension.present?
229
+ end
230
+ end
231
+ end
232
+ end
233
+
234
+ def must_support_elements
235
+ metadata.must_supports[:elements]
236
+ end
237
+
238
+ def missing_elements(resources = [])
239
+ @missing_elements ||= find_missing_elements(resources, must_support_elements)
240
+ end
241
+
242
+ def find_missing_elements(resources, must_support_elements)
243
+ must_support_elements.select do |element_definition|
244
+ resources.none? { |resource| resource_populates_element?(resource, element_definition) }
245
+ end
246
+ end
247
+
248
+ def resource_populates_element?(resource, element_definition)
249
+ path = element_definition[:path]
250
+ ms_extension_urls = must_support_extensions.select { |ex| ex[:path] == "#{path}.extension" }
251
+ .map { |ex| ex[:url] }
252
+
253
+ value_found = find_a_value_at(resource, path) do |potential_value|
254
+ matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
255
+ end
256
+
257
+ # Note that false.present? => false, which is why we need to add this extra check
258
+ value_found.present? || value_found == false
259
+ end
260
+
261
+ def matching_without_extensions?(value, ms_extension_urls, fixed_value)
262
+ if value.instance_of?(Inferno::DSL::PrimitiveType)
263
+ urls = value.extension&.map(&:url)
264
+ has_ms_extension = (urls & ms_extension_urls).present?
265
+ value = value.value
266
+ end
267
+
268
+ return false unless has_ms_extension || value_without_extensions?(value)
269
+
270
+ matches_fixed_value?(value, fixed_value)
271
+ end
272
+
273
+ def matches_fixed_value?(value, fixed_value)
274
+ fixed_value.blank? || value == fixed_value
275
+ end
276
+
277
+ def value_without_extensions?(value)
278
+ value_without_extensions = value.respond_to?(:to_hash) ? value.to_hash.except('extension') : value
279
+ value_without_extensions.present? || value_without_extensions == false
280
+ end
281
+
282
+ def must_support_slices
283
+ metadata.must_supports[:slices]
284
+ end
285
+
286
+ def missing_slices(resources = [])
287
+ @missing_slices ||=
288
+ must_support_slices.select do |slice|
289
+ resources.none? do |resource|
290
+ path = slice[:path]
291
+ find_slice(resource, path, slice[:discriminator]).present?
292
+ end
293
+ end
294
+ end
295
+
296
+ def find_slice(resource, path, discriminator)
297
+ # TODO: there is a lot of similarity
298
+ # between this and FHIRResourceNavigation.matching_slice?
299
+ # Can these be combined?
300
+ find_a_value_at(resource, path) do |element|
301
+ case discriminator[:type]
302
+ when 'patternCodeableConcept'
303
+ find_pattern_codeable_concept_slice(element, discriminator)
304
+ when 'patternCoding'
305
+ find_pattern_coding_slice(element, discriminator)
306
+ when 'patternIdentifier'
307
+ find_pattern_identifier_slice(element, discriminator)
308
+ when 'value'
309
+ find_value_slice(element, discriminator)
310
+ when 'type'
311
+ find_type_slice(element, discriminator)
312
+ when 'requiredBinding'
313
+ find_required_binding_slice(element, discriminator)
314
+ end
315
+ end
316
+ end
317
+
318
+ def find_pattern_codeable_concept_slice(element, discriminator)
319
+ coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
320
+ find_a_value_at(element, coding_path) do |coding|
321
+ coding.code == discriminator[:code] && coding.system == discriminator[:system]
322
+ end
323
+ end
324
+
325
+ def find_pattern_coding_slice(element, discriminator)
326
+ coding_path = discriminator[:path].present? ? discriminator[:path] : ''
327
+ find_a_value_at(element, coding_path) do |coding|
328
+ coding.code == discriminator[:code] && coding.system == discriminator[:system]
329
+ end
330
+ end
331
+
332
+ def find_pattern_identifier_slice(element, discriminator)
333
+ find_a_value_at(element, discriminator[:path]) do |identifier|
334
+ identifier.system == discriminator[:system]
335
+ end
336
+ end
337
+
338
+ def find_value_slice(element, discriminator)
339
+ values = discriminator[:values].map { |value| value.merge(path: value[:path].split('.')) }
340
+ find_slice_by_values(element, values)
341
+ end
342
+
343
+ def find_type_slice(element, discriminator)
344
+ case discriminator[:code]
345
+ when 'Date'
346
+ begin
347
+ Date.parse(element)
348
+ rescue ArgumentError
349
+ false
350
+ end
351
+ when 'DateTime'
352
+ begin
353
+ DateTime.parse(element)
354
+ rescue ArgumentError
355
+ false
356
+ end
357
+ when 'String'
358
+ element.is_a? String
359
+ else
360
+ element.is_a? FHIR.const_get(discriminator[:code])
361
+ end
362
+ end
363
+
364
+ def find_required_binding_slice(element, discriminator)
365
+ coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
366
+
367
+ find_a_value_at(element, coding_path) do |coding|
368
+ discriminator[:values].any? { |value| value[:system] == coding.system && value[:code] == coding.code }
369
+ end
370
+ end
371
+
372
+ def find_slice_by_values(element, value_definitions)
373
+ Array.wrap(element).find { |el| verify_slice_by_values(el, value_definitions) }
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../reference_extractor'
4
+
5
+ module Inferno
6
+ module DSL
7
+ module FHIREvaluation
8
+ module Rules
9
+ class AllReferencesResolve < Rule
10
+ def check(context)
11
+ extractor = Inferno::DSL::FHIREvaluation::ReferenceExtractor.new
12
+ resource_type_ids = extractor.extract_resource_type_ids(context.data)
13
+ resource_ids = Set.new(resource_type_ids.values.flatten.uniq)
14
+ reference_map = extractor.extract_references(context.data)
15
+
16
+ unresolved_references = Hash.new { |reference, id| reference[id] = [] }
17
+
18
+ reference_map.each do |resource_id, references|
19
+ references.each do |reference|
20
+ if reference[:type] == ''
21
+ unresolved_references[resource_id] << reference unless resource_ids.include?(reference[:id])
22
+ elsif !resource_type_ids[reference[:type]].include?(reference[:id])
23
+ unresolved_references[resource_id] << reference
24
+ end
25
+ end
26
+ end
27
+
28
+ if unresolved_references.any?
29
+ message = gen_reference_fail_message(unresolved_references)
30
+ result = EvaluationResult.new(message, rule: self)
31
+ else
32
+ message = 'All references resolve'
33
+ result = EvaluationResult.new(message, severity: 'success', rule: self)
34
+ end
35
+
36
+ context.add_result result
37
+ end
38
+
39
+ def gen_reference_fail_message(unresolved_references)
40
+ result_message = unresolved_references.map do |resource_id, references|
41
+ reference_detail = references.map do |reference|
42
+ " \n\tpath: #{reference[:path]}, type: #{reference[:type]}, id: #{reference[:id]}"
43
+ end.join(',')
44
+ "\n Resource (id): #{resource_id} #{reference_detail}"
45
+ end.join(',')
46
+
47
+ "Found unresolved references: #{result_message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../reference_extractor'
4
+
5
+ module Inferno
6
+ module DSL
7
+ module FHIREvaluation
8
+ module Rules
9
+ class AllResourcesReachable < Rule
10
+ attr_accessor :config, :referenced_resources, :referencing_resources, :resource_ids, :resource_type_ids
11
+
12
+ def check(context)
13
+ @config = context.config
14
+ @referenced_resources = Set.new
15
+ @referencing_resources = Set.new
16
+
17
+ extractor = Inferno::DSL::FHIREvaluation::ReferenceExtractor.new
18
+ @resource_type_ids = extractor.extract_resource_type_ids(context.data)
19
+ @resource_ids = Set.new(resource_type_ids.values.flatten.uniq)
20
+ reference_map = extractor.extract_references(context.data)
21
+
22
+ reference_map.each do |resource_id, references|
23
+ assess_reachability(resource_id, references)
24
+ end
25
+
26
+ island_resources = resource_ids - referenced_resources - referencing_resources
27
+ island_resources.to_a.sort!
28
+
29
+ if island_resources.any?
30
+ message = "Found resources that have no resolved references and are not referenced: #{
31
+ island_resources.join(', ')}"
32
+ result = EvaluationResult.new(message, rule: self)
33
+ else
34
+ message = 'All resources are reachable'
35
+ result = EvaluationResult.new(message, severity: 'success', rule: self)
36
+ end
37
+
38
+ context.add_result result
39
+ end
40
+
41
+ def assess_reachability(resource_id, references)
42
+ makes_resolvable_reference = false
43
+ references.each do |reference|
44
+ type = reference[:type]
45
+ referenced_id = reference[:id]
46
+
47
+ if type == ''
48
+ if resource_ids.include?(referenced_id)
49
+ makes_resolvable_reference = true
50
+ referenced_resources.add(referenced_id)
51
+ end
52
+ elsif resource_type_ids[type].include?(referenced_id)
53
+ makes_resolvable_reference = true
54
+ referenced_resources.add(referenced_id)
55
+ end
56
+ end
57
+ referencing_resources.add(resource_id) if makes_resolvable_reference
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end