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,226 @@
1
+ require_relative 'primitive_type'
2
+
3
+ module Inferno
4
+ module DSL
5
+ # The FHIRResourceNavigation module is used to pick values from a FHIR resource, based on a profile.
6
+ # Originally intended for use for verifying the presence of Must Support elements on a resource.
7
+ # This module expects pre-processed metadata defining the elements of the profile
8
+ # to be present in the attribute `metadata` in the including class.
9
+ # @see Inferno::DSL::MustSupportMetadataExtractor
10
+ module FHIRResourceNavigation
11
+ DAR_EXTENSION_URL = 'http://hl7.org/fhir/StructureDefinition/data-absent-reason'.freeze
12
+ PRIMITIVE_DATA_TYPES = FHIR::PRIMITIVES.keys
13
+
14
+ # Get a value from the given FHIR element(s) by walking the given path through the element.
15
+ # @param elements [FHIR::Model, Array<FHIR::Model>]
16
+ # @param path [String]
17
+ # @return [Array<FHIR::Model>]
18
+ def resolve_path(elements, path)
19
+ elements = Array.wrap(elements)
20
+ return elements if path.blank?
21
+
22
+ paths = path.split(/(?<!hl7)\./)
23
+ segment = paths.first
24
+ remaining_path = paths.drop(1).join('.')
25
+
26
+ elements.flat_map do |element|
27
+ child = get_next_value(element, segment)
28
+ resolve_path(child, remaining_path)
29
+ end.compact
30
+ end
31
+
32
+ # Get a value from the given FHIR element(s), by navigating through the resource to the given path.
33
+ # Fields with a DataAbsentReason extension present may be selected if include_dar is true.
34
+ # To filter the resulting elements, a block may be passed in.
35
+ # @param given_element [FHIR::Model, Array<FHIR::Model>]
36
+ # @param path [String]
37
+ # @param include_dar [Boolean]
38
+ # @return [Array<FHIR::Model>]
39
+ def find_a_value_at(given_element, path, include_dar: false, &block)
40
+ return nil if given_element.nil?
41
+
42
+ elements = Array.wrap(given_element)
43
+ return find_in_elements(elements, include_dar:, &block) if path.empty?
44
+
45
+ path_segments = path.split(/(?<!hl7)\./)
46
+
47
+ segment = path_segments.shift.delete_suffix('[x]').gsub(/^class$/, 'local_class').gsub('[x]:', ':').to_sym
48
+
49
+ remaining_path = path_segments.join('.')
50
+ elements.each do |element|
51
+ child = get_next_value(element, segment)
52
+ element_found = find_a_value_at(child, remaining_path, include_dar:, &block)
53
+ return element_found if element_found.present? || element_found == false
54
+ end
55
+
56
+ nil
57
+ end
58
+
59
+ def find_in_elements(elements, include_dar: false, &)
60
+ unless include_dar
61
+ elements = elements.reject do |el|
62
+ el.respond_to?(:extension) && el.extension.any? { |ext| ext.url == DAR_EXTENSION_URL }
63
+ end
64
+ end
65
+
66
+ return elements.find(&) if block_given?
67
+
68
+ elements.first
69
+ end
70
+
71
+ def get_next_value(element, property)
72
+ extension_url = property[/(?<=where\(url=').*(?='\))/]
73
+ if extension_url.present?
74
+ element.url == extension_url ? element : nil
75
+ elsif property.to_s.include?(':') && !property.to_s.include?('url')
76
+ find_slice_via_discriminator(element, property)
77
+
78
+ else
79
+ local_name = local_field_name(property)
80
+ value = element.send(local_name)
81
+ primitive_value = get_primitive_type_value(element, property, value)
82
+ primitive_value.present? ? primitive_value : value
83
+ end
84
+ rescue NoMethodError
85
+ nil
86
+ end
87
+
88
+ def get_primitive_type_value(element, property, value)
89
+ source_value = element.source_hash["_#{property}"]
90
+
91
+ return nil unless source_value.present?
92
+
93
+ primitive_value = PrimitiveType.new(source_value)
94
+ primitive_value.value = value
95
+ primitive_value
96
+ end
97
+
98
+ def local_field_name(field_name)
99
+ # fhir_models prepends fields whose names are reserved in ruby with "local_"
100
+ # This should be used before `x.send(field_name)`
101
+ if ['method', 'class'].include?(field_name.to_s)
102
+ "local_#{field_name}"
103
+ else
104
+ field_name
105
+ end
106
+ end
107
+
108
+ def find_slice_via_discriminator(element, property)
109
+ return unless metadata.present?
110
+
111
+ element_name = local_field_name(property.to_s.split(':')[0])
112
+ slice_name = local_field_name(property.to_s.split(':')[1])
113
+
114
+ slice_by_name = metadata.must_supports[:slices].find { |slice| slice[:slice_name] == slice_name }
115
+ discriminator = slice_by_name[:discriminator]
116
+ slices = Array.wrap(element.send(element_name))
117
+ slices.find { |slice| matching_slice?(slice, discriminator) }
118
+ end
119
+
120
+ def matching_slice?(slice, discriminator)
121
+ case discriminator[:type]
122
+ when 'patternCodeableConcept'
123
+ matching_pattern_codeable_concept_slice?(slice, discriminator)
124
+ when 'patternCoding'
125
+ matching_pattern_coding_slice?(slice, discriminator)
126
+ when 'patternIdentifier'
127
+ matching_pattern_identifier_slice?(slice, discriminator)
128
+ when 'value'
129
+ matching_value_slice?(slice, discriminator)
130
+ when 'type'
131
+ matching_type_slice?(slice, discriminator)
132
+ when 'requiredBinding'
133
+ matching_required_binding_slice?(slice, discriminator)
134
+ end
135
+ end
136
+
137
+ def matching_pattern_codeable_concept_slice?(slice, discriminator)
138
+ slice_value = discriminator[:path].present? ? slice.send((discriminator[:path]).to_s)&.coding : slice.coding
139
+ slice_value&.any? do |coding|
140
+ coding.code == discriminator[:code] && coding.system == discriminator[:system]
141
+ end
142
+ end
143
+
144
+ def matching_pattern_coding_slice?(slice, discriminator)
145
+ slice_value = discriminator[:path].present? ? slice.send(discriminator[:path]) : slice
146
+ slice_value&.code == discriminator[:code] && slice_value&.system == discriminator[:system]
147
+ end
148
+
149
+ def matching_pattern_identifier_slice?(slice, discriminator)
150
+ slice.identifier.system == discriminator[:system]
151
+ end
152
+
153
+ def matching_value_slice?(slice, discriminator)
154
+ values = discriminator[:values].map { |value| value.merge(path: value[:path].split('.')) }
155
+ verify_slice_by_values(slice, values)
156
+ end
157
+
158
+ def matching_type_slice?(slice, discriminator)
159
+ case discriminator[:code]
160
+ when 'Date'
161
+ begin
162
+ Date.parse(slice)
163
+ rescue ArgumentError
164
+ false
165
+ end
166
+ when 'DateTime'
167
+ begin
168
+ DateTime.parse(slice)
169
+ rescue ArgumentError
170
+ false
171
+ end
172
+ when 'String'
173
+ slice.is_a? String
174
+ else
175
+ slice.is_a? FHIR.const_get(discriminator[:code])
176
+ end
177
+ end
178
+
179
+ def matching_required_binding_slice?(slice, discriminator)
180
+ discriminator[:path].present? ? slice.send((discriminator[:path]).to_s).coding : slice.coding
181
+ slice_value { |coding| discriminator[:values].include?(coding.code) }
182
+ end
183
+
184
+ def verify_slice_by_values(element, value_definitions)
185
+ path_prefixes = value_definitions.map { |value_definition| value_definition[:path].first }.uniq
186
+ path_prefixes.all? do |path_prefix|
187
+ value_definitions_for_path =
188
+ value_definitions
189
+ .select { |value_definition| value_definition[:path].first == path_prefix }
190
+ .each { |value_definition| value_definition[:path].shift }
191
+ find_a_value_at(element, path_prefix) do |el_found|
192
+ current_and_child_values_match?(el_found, value_definitions_for_path)
193
+ end
194
+ end
195
+ end
196
+
197
+ def current_and_child_values_match?(el_found, value_definitions_for_path)
198
+ child_element_value_definitions, current_element_value_definitions =
199
+ value_definitions_for_path.partition { |value_definition| value_definition[:path].present? }
200
+
201
+ current_element_values_match =
202
+ current_element_value_definitions
203
+ .all? { |value_definition| value_definition[:value] == el_found }
204
+
205
+ child_element_values_match =
206
+ if child_element_value_definitions.present?
207
+ verify_slice_by_values(el_found, child_element_value_definitions)
208
+ else
209
+ true
210
+ end
211
+ current_element_values_match && child_element_values_match
212
+ end
213
+
214
+ def flatten_bundles(resources)
215
+ resources.flat_map do |resource|
216
+ if resource&.resourceType == 'Bundle'
217
+ # Recursive to consider that Bundles may contain Bundles
218
+ flatten_bundles(resource.entry.map(&:resource))
219
+ else
220
+ resource
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,366 @@
1
+ require_relative 'value_extractor'
2
+
3
+ module Inferno
4
+ module DSL
5
+ # The MustSupportMetadataExtractor takes a StructureDefinition and parses it into a hash-based metadata
6
+ # that simplifies checking for MustSupport elements.
7
+ # MustSupport elements may be either plain elements, extensions, or slices.
8
+ # This logic was originally developed for the US Core Test Kit and has been migrated into Inferno core.
9
+ class MustSupportMetadataExtractor
10
+ attr_accessor :profile_elements, :profile, :resource, :ig_resources, :requirement_extension_url
11
+
12
+ # Construct a new extractor
13
+ # @param profile_elements [Array<FHIR::ElementDefinition>] the elements of the profile to consider,
14
+ # ie, profile.snapshot.element
15
+ # @param profile [FHIR::StructureDefinition] the profile to parse
16
+ # @param resource [String] the resourceType that the profile applies to, ie, profile.type
17
+ # @param ig_resources [Inferno::Entities::IG]
18
+ # @param requirement_extension_url [String] the URL of an extension to flag elements as required even if not MS
19
+ def initialize(profile_elements, profile, resource, ig_resources, requirement_extension_url = nil)
20
+ self.profile_elements = profile_elements
21
+ self.profile = profile
22
+ self.resource = resource
23
+ self.ig_resources = ig_resources
24
+ self.requirement_extension_url = requirement_extension_url
25
+ end
26
+
27
+ # Retrieval method for the must support metadata
28
+ # @return [Hash]
29
+ def must_supports
30
+ @must_supports ||= {
31
+ extensions: must_support_extensions,
32
+ slices: must_support_slices,
33
+ elements: must_support_elements
34
+ }
35
+ end
36
+
37
+ def by_requirement_extension_only?(element)
38
+ requirement_extension_url && !element.mustSupport &&
39
+ element.extension.any? do |extension|
40
+ extension.url == requirement_extension_url && extension.valueBoolean
41
+ end
42
+ end
43
+
44
+ def all_must_support_elements
45
+ profile_elements.select { |element| element.mustSupport || by_requirement_extension_only?(element) }
46
+ end
47
+
48
+ def must_support_extension_elements
49
+ all_must_support_elements.select { |element| element.path.end_with? 'extension' }
50
+ end
51
+
52
+ def must_support_extensions
53
+ must_support_extension_elements.map do |element|
54
+ {
55
+ id: element.id,
56
+ path: element.path.gsub("#{resource}.", ''),
57
+ url: element.type.first.profile.first
58
+ }.tap do |metadata|
59
+ metadata[:by_requirement_extension_only] = true if by_requirement_extension_only?(element)
60
+ end
61
+ end
62
+ end
63
+
64
+ def must_support_slice_elements
65
+ all_must_support_elements.select do |element|
66
+ !element.path.end_with?('extension') && element.sliceName.present?
67
+ end
68
+ end
69
+
70
+ def sliced_element(slice)
71
+ profile_elements.find do |element|
72
+ element.id == slice.path || element.id == slice.id.sub(":#{slice.sliceName}", '')
73
+ end
74
+ end
75
+
76
+ def discriminators(slice)
77
+ slice&.slicing&.discriminator
78
+ end
79
+
80
+ def find_element_by_discriminator_path(current_element, discriminator_path)
81
+ if discriminator_path.present?
82
+ profile_elements.find { |element| element.id == "#{current_element.id}.#{discriminator_path}" } ||
83
+ profile_elements.find { |element| element.id == "#{current_element.path}.#{discriminator_path}" }
84
+ else
85
+ current_element
86
+ end
87
+ end
88
+
89
+ def save_pattern_slice(pattern_element, discriminator_path, metadata)
90
+ if pattern_element.patternCodeableConcept
91
+ {
92
+ type: 'patternCodeableConcept',
93
+ path: discriminator_path,
94
+ code: pattern_element.patternCodeableConcept.coding.first.code,
95
+ system: pattern_element.patternCodeableConcept.coding.first.system
96
+ }
97
+ elsif pattern_element.patternCoding
98
+ {
99
+ type: 'patternCoding',
100
+ path: discriminator_path,
101
+ code: pattern_element.patternCoding.code,
102
+ system: pattern_element.patternCoding.system
103
+ }
104
+ elsif pattern_element.patternIdentifier
105
+ {
106
+ type: 'patternIdentifier',
107
+ path: discriminator_path,
108
+ system: pattern_element.patternIdentifier.system
109
+ }
110
+ elsif required_binding_pattern?(pattern_element)
111
+ {
112
+ type: 'requiredBinding',
113
+ path: discriminator_path,
114
+ values: extract_required_binding_values(pattern_element, metadata)
115
+ }
116
+ else
117
+ # prevent errors in case an IG does something different
118
+ {
119
+ type: 'unsupported',
120
+ path: discriminator_path
121
+ }
122
+ end
123
+ end
124
+
125
+ def required_binding_pattern?(pattern_element)
126
+ pattern_element.binding&.strength == 'required' && pattern_element.binding&.valueSet
127
+ end
128
+
129
+ def extract_required_binding_values(pattern_element, metadata)
130
+ value_extractor = ValueExtractor.new(ig_resources, resource, profile_elements)
131
+
132
+ value_extractor.codings_from_value_set_binding(pattern_element).presence ||
133
+ value_extractor.values_from_resource_metadata([metadata[:path]]).presence || []
134
+ end
135
+
136
+ def must_support_type_slice_elements
137
+ must_support_slice_elements.select do |element|
138
+ discriminators(sliced_element(element))&.first&.type == 'type'
139
+ end
140
+ end
141
+
142
+ def discriminator_path(discriminator)
143
+ if discriminator.path == '$this'
144
+ ''
145
+ elsif discriminator.path.start_with?('$this.')
146
+ discriminator.path[6..]
147
+ else
148
+ discriminator.path
149
+ end
150
+ end
151
+
152
+ def type_slices
153
+ must_support_type_slice_elements.map do |current_element|
154
+ discriminator = discriminators(sliced_element(current_element)).first
155
+ type_path = discriminator_path(discriminator)
156
+ type_element = find_element_by_discriminator_path(current_element, type_path)
157
+
158
+ type_code = type_element.type.first.code
159
+
160
+ {
161
+ slice_id: current_element.id,
162
+ slice_name: current_element.sliceName,
163
+ path: current_element.path.gsub("#{resource}.", ''),
164
+ discriminator: {
165
+ type: 'type',
166
+ code: type_code.upcase_first
167
+ }
168
+ }.tap do |metadata|
169
+ metadata[:by_requirement_extension_only] = true if by_requirement_extension_only?(current_element)
170
+ end
171
+ end
172
+ end
173
+
174
+ def must_support_value_slice_elements
175
+ must_support_slice_elements.select do |element|
176
+ # discriminator type 'pattern' is deprecated in FHIR R5 and made equivalent to 'value'
177
+ ['value', 'pattern'].include?(discriminators(sliced_element(element))&.first&.type)
178
+ end
179
+ end
180
+
181
+ def value_slices # rubocop:disable Metrics/CyclomaticComplexity
182
+ must_support_value_slice_elements.map do |current_element|
183
+ {
184
+ slice_id: current_element.id,
185
+ slice_name: current_element.sliceName,
186
+ path: current_element.path.gsub("#{resource}.", '')
187
+ }.tap do |metadata|
188
+ fixed_values = []
189
+ pattern_value = {}
190
+
191
+ element_discriminators = discriminators(sliced_element(current_element))
192
+
193
+ element_discriminators.each do |discriminator|
194
+ discriminator_path = discriminator_path(discriminator)
195
+ pattern_element = find_element_by_discriminator_path(current_element, discriminator_path)
196
+
197
+ # This is a workaround for a known version of a profile that has a bad discriminator:
198
+ # the discriminator refers to a nested field within a CodeableConcept,
199
+ # but the profile doesn't contain an element definition for it, so there's no way to
200
+ # define a fixed value on the element to define the slice.
201
+ # In this instance the element has a second (good) discriminator on the CodeableConcept field itself,
202
+ # and in subsequent versions of the profile, the bad discriminator was removed.
203
+ next if pattern_element.nil? && element_discriminators.length > 1
204
+
205
+ if pattern_element.fixed.present?
206
+ fixed_values << {
207
+ path: discriminator_path,
208
+ value: pattern_element.fixed
209
+ }
210
+ elsif pattern_value.present?
211
+ raise StandardError, "Found more than one pattern slices for the same element #{pattern_element}."
212
+ else
213
+ pattern_value = save_pattern_slice(pattern_element, discriminator_path, metadata)
214
+ end
215
+ end
216
+
217
+ if fixed_values.present?
218
+ metadata[:discriminator] = {
219
+ type: 'value',
220
+ values: fixed_values
221
+ }
222
+ elsif pattern_value.present?
223
+ metadata[:discriminator] = pattern_value
224
+ end
225
+
226
+ metadata[:by_requirement_extension_only] = true if by_requirement_extension_only?(current_element)
227
+ end
228
+ end
229
+ end
230
+
231
+ def must_support_slices
232
+ type_slices + value_slices
233
+ end
234
+
235
+ def plain_must_support_elements
236
+ all_must_support_elements - must_support_extension_elements - must_support_slice_elements
237
+ end
238
+
239
+ def element_part_of_slice_discrimination?(element)
240
+ must_support_slice_elements.any? { |ms_slice| element.id.include?(ms_slice.id) }
241
+ end
242
+
243
+ def handle_fixed_values(metadata, element)
244
+ if element.fixed.present?
245
+ metadata[:fixed_value] = element.fixed
246
+ elsif element.patternCodeableConcept.present? && !element_part_of_slice_discrimination?(element)
247
+ metadata[:fixed_value] = element.patternCodeableConcept.coding.first.code
248
+ metadata[:path] += '.coding.code'
249
+ elsif element.fixedCode.present?
250
+ metadata[:fixed_value] = element.fixedCode
251
+ elsif element.patternIdentifier.present? && !element_part_of_slice_discrimination?(element)
252
+ metadata[:fixed_value] = element.patternIdentifier.system
253
+ metadata[:path] += '.system'
254
+ end
255
+ end
256
+
257
+ def type_must_support_extension?(extensions)
258
+ extensions&.any? do |extension|
259
+ extension.url == 'http://hl7.org/fhir/StructureDefinition/elementdefinition-type-must-support' &&
260
+ extension.valueBoolean
261
+ end
262
+ end
263
+
264
+ def save_type_code?(type)
265
+ type.code == 'Reference'
266
+ end
267
+
268
+ def get_type_must_support_metadata(current_metadata, current_element)
269
+ current_element.type.map do |type|
270
+ next unless type_must_support_extension?(type.extension)
271
+
272
+ metadata =
273
+ {
274
+ path: "#{current_metadata[:path].delete_suffix('[x]')}#{type.code.upcase_first}",
275
+ original_path: current_metadata[:path]
276
+ }
277
+ metadata[:types] = [type.code] if save_type_code?(type)
278
+ handle_type_must_support_target_profiles(type, metadata) if type.code == 'Reference'
279
+
280
+ metadata
281
+ end.compact
282
+ end
283
+
284
+ def handle_type_must_support_target_profiles(type, metadata)
285
+ target_profiles = extract_target_profiles(type)
286
+
287
+ # remove target_profile for FHIR Base resource type.
288
+ target_profiles.delete_if { |reference| reference.start_with?('http://hl7.org/fhir/StructureDefinition') }
289
+ metadata[:target_profiles] = target_profiles if target_profiles.present?
290
+ end
291
+
292
+ def extract_target_profiles(type)
293
+ target_profiles = []
294
+
295
+ if type.targetProfile&.length == 1
296
+ target_profiles << type.targetProfile.first
297
+ else
298
+ type.source_hash['_targetProfile']&.each_with_index do |hash, index|
299
+ if hash.present?
300
+ element = FHIR::Element.new(hash)
301
+ target_profiles << type.targetProfile[index] if type_must_support_extension?(element.extension)
302
+ end
303
+ end
304
+ end
305
+
306
+ target_profiles
307
+ end
308
+
309
+ def handle_choice_type_in_sliced_element(current_metadata, must_support_elements_metadata)
310
+ choice_element_metadata = must_support_elements_metadata.find do |metadata|
311
+ metadata[:original_path].present? &&
312
+ current_metadata[:path].include?(metadata[:original_path])
313
+ end
314
+
315
+ return unless choice_element_metadata.present?
316
+
317
+ current_metadata[:original_path] = current_metadata[:path]
318
+ current_metadata[:path] =
319
+ current_metadata[:path].sub(choice_element_metadata[:original_path], choice_element_metadata[:path])
320
+ end
321
+
322
+ def must_support_elements
323
+ must_support_elements_metadata = []
324
+ plain_must_support_elements.each do |current_element|
325
+ current_metadata = {
326
+ path: current_element.id.gsub("#{resource}.", '')
327
+ }
328
+ current_metadata[:by_requirement_extension_only] = true if by_requirement_extension_only?(current_element)
329
+
330
+ type_must_support_metadata = get_type_must_support_metadata(current_metadata, current_element)
331
+
332
+ if type_must_support_metadata.any?
333
+ must_support_elements_metadata.concat(type_must_support_metadata)
334
+ else
335
+ handle_choice_type_in_sliced_element(current_metadata, must_support_elements_metadata)
336
+
337
+ supported_types = extract_supported_types(current_element)
338
+ current_metadata[:types] = supported_types if supported_types.present?
339
+
340
+ if current_element.type.first&.code == 'Reference'
341
+ handle_type_must_support_target_profiles(current_element.type.first,
342
+ current_metadata)
343
+ end
344
+
345
+ handle_fixed_values(current_metadata, current_element)
346
+
347
+ remove_conflicting_metadata_without_fixed_value(must_support_elements_metadata, current_metadata)
348
+
349
+ must_support_elements_metadata << current_metadata
350
+ end
351
+ end
352
+ must_support_elements_metadata.uniq
353
+ end
354
+
355
+ def extract_supported_types(current_element)
356
+ current_element.type.select { |type| save_type_code?(type) }.map(&:code)
357
+ end
358
+
359
+ def remove_conflicting_metadata_without_fixed_value(must_support_elements_metadata, current_metadata)
360
+ must_support_elements_metadata.delete_if do |metadata|
361
+ metadata[:path] == current_metadata[:path] && metadata[:fixed_value].blank?
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,9 @@
1
+ require 'fhir_models'
2
+
3
+ module Inferno
4
+ module DSL
5
+ class PrimitiveType < FHIR::Element
6
+ attr_accessor :value
7
+ end
8
+ end
9
+ end