inferno_core 0.6.0 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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