inferno_core 1.4.1 → 1.4.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d24d5fad7804605708193855dec5e392045301a3a02ed94136d02f659a5f45e
4
- data.tar.gz: df3d42baee167e540a41f3d4a5bc53afd307751c695b4202e1886c910468736c
3
+ metadata.gz: 044cfcbc6d3920cfba55d0d63fbf0fb3a1440f4f42a2e45d763beb78f280306a
4
+ data.tar.gz: 243c006acc71fddd3eb165a2c400a1eab4e515f8c3f4915b27cd60fc8e41e734
5
5
  SHA512:
6
- metadata.gz: 5286cc2c1c41c90a48e81806330d366eb87157b9a8f7d6f183db7919486925af880707d0728b504b42356a701c206b4b4c7c0eed07762ea4cf4d5605b68934fe
7
- data.tar.gz: 4e722dffacb5ffbff4fae5827718cac7d77ad331f4675c14db8a873766cb9a71cb690e0e3c1dbe16b7e9d6d5a0ba3e3b55ee2f247d07cc9133503870a6377c0b
6
+ metadata.gz: a1c934c0c6bb863a66e34b2830807ee274f443323d7c74c10023bf078329abf2bc70d9125f481b388c3e9ac61a647c81a8f6ee6f7c156078a9dc4fc31adcb671
7
+ data.tar.gz: 6a54d11609e88fadd4c4750966e66a1df705ab1a1c79b8c8c0b99f5d3a19467496c46db740dfb2937610c9c78e8a7bf14db7fe8112c7c8399bb43a16bdcd6937
@@ -137,8 +137,8 @@ module Inferno
137
137
  )
138
138
 
139
139
  ExecutionStatus = Struct.new(
140
- :done, :failed, :timed_out, :current_session, :current_timeout, :last_log_time,
141
- :cross_session_status, :last_step_signatures
140
+ :done, :failed, :timed_out, :cancel_pending, :current_session, :current_timeout, :last_log_time,
141
+ :poll_start_time, :cross_session_status, :last_step_signatures
142
142
  )
143
143
 
144
144
  attr_accessor :yaml_file, :options, :execution_status
@@ -152,8 +152,10 @@ module Inferno
152
152
  done: false,
153
153
  failed: false,
154
154
  timed_out: false,
155
+ cancel_pending: false,
155
156
  current_session: sessions.first,
156
157
  current_timeout: options[:default_poll_timeout],
158
+ poll_start_time: nil,
157
159
  cross_session_status: {},
158
160
  last_step_signatures: {}
159
161
  )
@@ -234,13 +236,15 @@ module Inferno
234
236
  creator = Session::CreateSession.new(suite, session_create_options(session_config))
235
237
  session_details = creator.create_session
236
238
  key = session_config['name'] || suite
237
- warn "Session created: #{session_details['id']}"
238
- ScriptSession.new(
239
+ script_session = ScriptSession.new(
239
240
  key: key,
240
241
  suite_id: session_details['test_suite_id'],
241
242
  session_id: session_details['id'],
242
243
  short_id_map: extract_short_ids_from_session_details(session_details)
243
244
  )
245
+ warn "Session created: #{session_details['id']}"
246
+ warn " Available at #{session_display_url(script_session)}"
247
+ script_session
244
248
  end
245
249
 
246
250
  def session_create_options(session_config)
@@ -413,6 +417,7 @@ module Inferno
413
417
  warn ''
414
418
  warn "Polling session: #{session.key} (#{session.session_id}) timeout=#{timeout}s"
415
419
  deadline = Time.now + timeout
420
+ execution_status.poll_start_time = Time.now
416
421
  execution_status.last_log_time = Time.now - LOG_INTERVAL_SECONDS
417
422
 
418
423
  loop do
@@ -442,6 +447,10 @@ module Inferno
442
447
 
443
448
  # Returns a step hash to act on, or nil to keep polling.
444
449
  def handle_actionable_status(status, session, timeout)
450
+ # A cancelled run's done status is indistinguishable from the
451
+ # runnable completing normally, so don't match steps against it.
452
+ return handle_cancel_completion(status, session, timeout) if execution_status.cancel_pending
453
+
445
454
  matched_step = match_step(status, session.key)
446
455
 
447
456
  if matched_step
@@ -463,10 +472,16 @@ module Inferno
463
472
  last_completed = format_last_completed(last_completed_from_status(status), session.key)
464
473
  warn "UNHANDLED WAIT - Canceling: session=#{session.key} last_completed=#{last_completed}"
465
474
  execution_status.failed = true
475
+ execution_status.cancel_pending = true
466
476
  attempt_cancel(session.session_id, status)
467
477
  nil
468
478
  end
469
479
 
480
+ def handle_cancel_completion(status, session, timeout)
481
+ warn "Cancellation complete: session=#{session.key} status=#{status['status']}"
482
+ { command: nil, timeout: timeout, next_poll_session: nil }
483
+ end
484
+
470
485
  def handle_unmatched_status(status, session, timeout)
471
486
  run_status = status['status']
472
487
  last_completed = format_last_completed(last_completed_from_status(status), session.key)
@@ -485,6 +500,7 @@ module Inferno
485
500
  execution_status.failed = true
486
501
  if run_status == 'waiting'
487
502
  warn "Loop detected - Canceling: session=#{session.key} last_completed=#{last_completed}"
503
+ execution_status.cancel_pending = true
488
504
  attempt_cancel(session.session_id, status)
489
505
  return nil
490
506
  else
@@ -504,10 +520,16 @@ module Inferno
504
520
  last_completed = last_completed_from_status(status)
505
521
  poll_status_last_test =
506
522
  last_completed.present? ? " - last test: #{format_last_completed(last_completed, session_key)}" : ''
507
- warn " [#{session_key}] #{status['status']}#{poll_status_last_test}"
523
+ elapsed = (Time.now - execution_status.poll_start_time).round
524
+ warn " [#{session_key}] #{status['status']} (#{test_progress(status, elapsed)})" \
525
+ "#{poll_status_last_test}"
508
526
  execution_status.last_log_time = Time.now
509
527
  end
510
528
 
529
+ def test_progress(status, elapsed)
530
+ "completed #{status['completed_test_count']}/#{status['test_count']} tests in #{elapsed}s"
531
+ end
532
+
511
533
  def fetch_session_status(session_id)
512
534
  Session::SessionStatus.new(session_id, options).status_for_session
513
535
  end
@@ -119,7 +119,7 @@ module Inferno
119
119
  suite_runnables.select { |runnable| runnable.verifies_requirements.include? requirement_id }
120
120
 
121
121
  runnables_for_requirement.map do |runnable|
122
- [requirement_id, runnable.short_id, runnable.id]
122
+ [requirement_id, runnable < Inferno::Entities::TestSuite ? 'suite' : runnable.short_id, runnable.id]
123
123
  end
124
124
  end
125
125
  end
@@ -1,5 +1,6 @@
1
1
  require 'csv'
2
2
  require 'cgi'
3
+ require 'uri'
3
4
  require_relative 'session_details'
4
5
  require_relative 'session_results'
5
6
 
@@ -82,16 +83,39 @@ module Inferno
82
83
  end
83
84
 
84
85
  def save_actual_results_to_file
85
- actual_results_file_name = "#{output_file_prefix}actual_results_#{results_timestamp}.json"
86
- File.write(File.join(output_directory, actual_results_file_name), session_results.to_json)
86
+ actual_results_file_name = "#{output_file_prefix}actual_results_#{results_timestamp}#{host_suffix}.json"
87
+ File.write(File.join(output_directory, actual_results_file_name), JSON.pretty_generate(session_results))
87
88
  end
88
89
 
89
90
  def save_comparison_csv_to_file
90
- compared_csv_file_name = "#{output_file_prefix}compared_results_#{results_timestamp}.csv"
91
+ compared_csv_file_name = "#{output_file_prefix}compared_results_#{results_timestamp}#{host_suffix}.csv"
91
92
  File.write(File.join(output_directory, compared_csv_file_name),
92
93
  compared_results_as_csv)
93
94
  end
94
95
 
96
+ # Filesystem-safe "_<host>" (plus non-default port, if any) suffix derived from
97
+ # the --inferno-base-url (-I) option, so output filenames can be disambiguated
98
+ # by target server. Empty when -I was not passed or the URL has no host.
99
+ def host_suffix
100
+ inferno_host_slug ? "_#{inferno_host_slug}" : ''
101
+ end
102
+
103
+ def inferno_host_slug
104
+ return nil unless options[:inferno_base_url].present?
105
+
106
+ uri = begin
107
+ URI.parse(options[:inferno_base_url])
108
+ rescue URI::InvalidURIError
109
+ nil
110
+ end
111
+ host = uri&.host
112
+ return nil unless host
113
+
114
+ slug = host.dup
115
+ slug << "_#{uri.port}" if uri.port && uri.port != uri.default_port
116
+ slug.gsub(/[^a-zA-Z0-9.-]/, '_')
117
+ end
118
+
95
119
  def display_compared_results
96
120
  output = {
97
121
  matched: results_match?,
@@ -27,7 +27,10 @@ module Inferno
27
27
 
28
28
  if session_status['id'].present?
29
29
  run_id = session_status['id']
30
- last_test_executed = last_test_executed(run_id)
30
+ test_results = run_results(run_id).select { |result| result['test_id'].present? }
31
+ session_status['completed_test_count'] = test_results.size
32
+
33
+ last_test_executed = last_test_executed(test_results, session_status['status'])
31
34
  if last_test_executed.present?
32
35
  session_status['last_test_executed'] = last_test_executed['test_id']
33
36
  if session_status['status'] == 'waiting'
@@ -53,9 +56,22 @@ module Inferno
53
56
  }
54
57
  end
55
58
 
56
- def last_test_executed(run_id)
57
- results = run_results(run_id)
58
- results.sort_by { |r| r['updated_at'] }.reverse.find { |result| result['test_id'].present? }
59
+ # Serialized updated_at values can collide when results are written in
60
+ # quick succession, so when the run is waiting, identify the waiting
61
+ # test by its 'wait' result rather than by timestamp order alone.
62
+ def last_test_executed(test_results, run_status)
63
+ if run_status == 'waiting'
64
+ wait_results = test_results.select { |result| result['result'] == 'wait' }
65
+ test_results = wait_results if wait_results.any?
66
+ end
67
+
68
+ most_recent_result(test_results)
69
+ end
70
+
71
+ # updated_at ties are broken by array position (later results win)
72
+ # since the API returns results in insertion order.
73
+ def most_recent_result(results)
74
+ results.each_with_index.max_by { |result, index| [result['updated_at'].to_s, index] }&.first
59
75
  end
60
76
 
61
77
  def run_results(run_id)
@@ -37,7 +37,14 @@ module Inferno
37
37
 
38
38
  persist_inputs(session_data_repo, req.params, test_run.runnable)
39
39
 
40
- Jobs.perform(Jobs::ExecuteTestRun, test_run.id)
40
+ Jobs.perform(
41
+ Jobs::ExecuteTestRun,
42
+ test_run.id,
43
+ tags: [
44
+ "session:#{test_session.id}",
45
+ "run:#{test_run.test_suite_id || test_run.test_group_id || test_run.test_id}"
46
+ ]
47
+ )
41
48
  rescue Sequel::ValidationFailed, Sequel::ForeignKeyConstraintViolation,
42
49
  Inferno::Exceptions::RequiredInputsNotFound,
43
50
  Inferno::Exceptions::NotUserRunnableException => e
@@ -22,7 +22,16 @@ module Inferno
22
22
  if test_run_is_waiting
23
23
  waiting_result = results_repo.find_waiting_result(test_run_id: test_run.id)
24
24
  results_repo.update_result(waiting_result.id, 'cancel', 'Test cancelled by user')
25
- Jobs.perform(Jobs::ResumeTestRun, test_run.id)
25
+ Jobs.perform(
26
+ Jobs::ResumeTestRun,
27
+ test_run.id,
28
+ tags: [
29
+ 'source:delete',
30
+ "session:#{test_run.test_session_id}",
31
+ "run:#{test_run.test_suite_id || test_run.test_group_id || test_run.test_id}",
32
+ "test:#{waiting_result.test_id}"
33
+ ]
34
+ )
26
35
  end
27
36
 
28
37
  res.status = 204
@@ -1,6 +1,46 @@
1
1
  require 'sequel'
2
2
  require 'erb'
3
3
 
4
+ module Inferno
5
+ module Config
6
+ module Boot
7
+ # Extracted from the :db provider below so the sqlite-only branching can
8
+ # be unit tested with plain config hashes/doubles, instead of requiring
9
+ # a real postgres connection (which this project has no infrastructure
10
+ # for) just to exercise the non-sqlite path.
11
+ module Db
12
+ module_function
13
+
14
+ # The web and worker processes each hold their own pool of connections to the
15
+ # same sqlite file, so writes from one process can easily collide with reads
16
+ # or writes from another. WAL mode lets readers proceed without blocking on a
17
+ # concurrent writer, and a longer busy_timeout gives a blocked writer more
18
+ # room to wait its turn instead of immediately raising SQLITE_BUSY.
19
+ #
20
+ # If you change this, run `bundle exec rake db:check_concurrency` to verify
21
+ # SQLITE_BUSY is still avoided under concurrent test-run writes and status
22
+ # polling (see lib/inferno/utils/db_concurrency_check.rb for why that's a
23
+ # rake task and not a spec).
24
+ def configure_sqlite_pragmas!(config)
25
+ return config unless config['adapter'] == 'sqlite'
26
+
27
+ connect_sqls = ["PRAGMA busy_timeout = #{ENV.fetch('DB_BUSY_TIMEOUT_MS', '15000')}"]
28
+ connect_sqls << 'PRAGMA journal_mode = WAL' unless config['database'] == ':memory:'
29
+ config['connect_sqls'] = connect_sqls
30
+ config
31
+ end
32
+
33
+ def log_sqlite_journal_mode(config, connection, logger)
34
+ return unless config['adapter'] == 'sqlite'
35
+
36
+ actual_journal_mode = connection.fetch('PRAGMA journal_mode').first[:journal_mode]
37
+ logger.info("sqlite journal_mode: #{actual_journal_mode}")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
4
44
  Inferno::Application.register_provider(:db) do
5
45
  prepare do
6
46
  target_container.start :logging
@@ -13,6 +53,9 @@ Inferno::Application.register_provider(:db) do
13
53
  config_contents = ERB.new(File.read(config_path)).result
14
54
  config = YAML.safe_load(config_contents)[ENV.fetch('APP_ENV', nil)]
15
55
  .merge(logger: Inferno::Application['logger'])
56
+
57
+ Inferno::Config::Boot::Db.configure_sqlite_pragmas!(config)
58
+
16
59
  connection_attempts_remaining = ENV.fetch('MAX_DB_CONNECTION_ATTEMPTS', '10').to_i
17
60
  connection_retry_delay = ENV.fetch('DB_CONNECTION_RETRY_DELAY', '5').to_i
18
61
  connection = nil
@@ -31,6 +74,8 @@ Inferno::Application.register_provider(:db) do
31
74
  end
32
75
  connection.sql_log_level = :debug
33
76
 
77
+ Inferno::Config::Boot::Db.log_sqlite_journal_mode(config, connection, Inferno::Application['logger'])
78
+
34
79
  register('db.config', config)
35
80
  register('db.connection', connection)
36
81
  end
@@ -7,6 +7,10 @@ Inferno::Application.register_provider(:executor) do
7
7
 
8
8
  Blueprinter.configure do |config|
9
9
  config.generator = Oj
10
+ # Oj >= 3.17 no longer honors ActiveSupport's Time#to_json, which
11
+ # dropped sub-second precision from serialized timestamps. Clients
12
+ # order results by these strings, so keep millisecond precision.
13
+ config.datetime_format = '%FT%T.%L%:z'
10
14
  end
11
15
 
12
16
  target_container.start :suites
@@ -5,6 +5,10 @@ Inferno::Application.register_provider(:web) do |_app|
5
5
 
6
6
  Blueprinter.configure do |config|
7
7
  config.generator = Oj
8
+ # Oj >= 3.17 no longer honors ActiveSupport's Time#to_json, which
9
+ # dropped sub-second precision from serialized timestamps. Clients
10
+ # order results by these strings, so keep millisecond precision.
11
+ config.datetime_format = '%FT%T.%L%:z'
8
12
  end
9
13
 
10
14
  # Workers aren't connected to a web server, so they shouldn't be hosting
@@ -42,8 +42,13 @@ module Inferno
42
42
  # @param given_element [FHIR::Model, Array<FHIR::Model>]
43
43
  # @param path [String]
44
44
  # @param include_dar [Boolean]
45
+ # @param recursive_segments [Array<String>] names of path segments that are
46
+ # self-referential (eg. `item` in Questionnaire.item.item, via a FHIR
47
+ # `contentReference`). When a segment matches, the search also continues
48
+ # into deeper repeats of that segment (item.item.item...) if the value
49
+ # isn't found at the literal path depth.
45
50
  # @return a single matching value (which can include `false`) or `nil` if not found
46
- def find_a_value_at(given_element, path, include_dar: false, &block)
51
+ def find_a_value_at(given_element, path, include_dar: false, recursive_segments: [], &block)
47
52
  return nil if given_element.nil?
48
53
 
49
54
  elements = Array.wrap(given_element)
@@ -56,8 +61,13 @@ module Inferno
56
61
  remaining_path = path_segments.join('.')
57
62
  elements.each do |element|
58
63
  child = get_next_value(element, segment)
59
- element_found = find_a_value_at(child, remaining_path, include_dar:, &block)
64
+ element_found = find_a_value_at(child, remaining_path, include_dar:, recursive_segments:, &block)
60
65
  return element_found if value_not_empty?(element_found)
66
+
67
+ next unless recursive_segments.include?(segment)
68
+
69
+ nested_found = find_a_value_at(child, path, include_dar:, recursive_segments:, &block)
70
+ return nested_found if value_not_empty?(nested_found)
61
71
  end
62
72
 
63
73
  nil
@@ -26,6 +26,7 @@ module Inferno
26
26
  # allowExampleUrls true
27
27
  # txServer nil
28
28
  # end
29
+ # expansion_parameters 'path/to/expansion_parameters.json'
29
30
  # end
30
31
  module FHIRResourceValidation
31
32
  def self.included(klass)
@@ -118,6 +119,99 @@ module Inferno
118
119
 
119
120
  alias cli_context validation_context
120
121
 
122
+ # Environment variable containing the default expansion parameters.
123
+ # Used by {#expansion_parameters} when no value has been set
124
+ # explicitly. May contain either the raw JSON content of a FHIR
125
+ # Parameters resource (if it starts with `{`) or a path to a file
126
+ # containing one.
127
+ EXPANSION_PARAMETERS_ENV_VAR = 'FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS'.freeze
128
+
129
+ # Set the expansion parameters to be sent with each validation
130
+ # request. This configures how the validator's terminology engine
131
+ # expands value sets during validation (e.g. designation
132
+ # preferences, forcing the use of the latest terminology versions,
133
+ # etc.). The content is sent inline with every validation request
134
+ # made by this validator, since the validator does not have access
135
+ # to Inferno's filesystem.
136
+ #
137
+ # Accepts either a Hash containing the contents of a FHIR Parameters
138
+ # resource, or a String path to a file (JSON or XML) containing one.
139
+ # The file is read once, the first time it's needed.
140
+ #
141
+ # If never set explicitly, this falls back to the
142
+ # `FHIR_RESOURCE_VALIDATOR_EXPANSION_PARAMETERS` environment
143
+ # variable, if present. This allows a shared set of expansion
144
+ # parameters to be configured once for every test kit that uses a
145
+ # given validator instance, while still letting individual test
146
+ # kits opt out or override it by calling this method themselves.
147
+ # The environment variable's content is treated as raw JSON if it
148
+ # starts with `{`, and otherwise as a file path.
149
+ #
150
+ # @example
151
+ # # Passing a Hash
152
+ # fhir_resource_validator do
153
+ # url 'http://example.com/validator'
154
+ # expansion_parameters({
155
+ # resourceType: 'Parameters',
156
+ # parameter: [{ name: 'excludeNested', valueBoolean: true }]
157
+ # })
158
+ # end
159
+ #
160
+ # @example
161
+ # # Passing a file path
162
+ # fhir_resource_validator do
163
+ # url 'http://example.com/validator'
164
+ # expansion_parameters 'path/to/expansion_parameters.json'
165
+ # end
166
+ #
167
+ # @param value [Hash, String, nil] contents of a Parameters resource
168
+ # as a Hash, or a path to a file (JSON or XML) containing one
169
+ def expansion_parameters(value = nil)
170
+ if value
171
+ @expansion_parameters = build_expansion_parameters(value)
172
+ elsif !@expansion_parameters_resolved
173
+ env_value = ENV.fetch(EXPANSION_PARAMETERS_ENV_VAR, nil)
174
+ @expansion_parameters = build_expansion_parameters_from_env(env_value) if env_value
175
+ end
176
+ @expansion_parameters_resolved = true
177
+
178
+ @expansion_parameters
179
+ end
180
+
181
+ # @private
182
+ # Determines whether the environment variable's content is raw JSON
183
+ # or a file path based on its first non-whitespace character.
184
+ def build_expansion_parameters_from_env(env_value)
185
+ if env_value.lstrip.start_with?('{')
186
+ build_expansion_parameters_from_json_content(env_value)
187
+ else
188
+ build_expansion_parameters(env_value)
189
+ end
190
+ end
191
+
192
+ # @private
193
+ def build_expansion_parameters(value)
194
+ case value
195
+ when Hash
196
+ build_expansion_parameters_from_json_content(value.to_json)
197
+ else
198
+ {
199
+ fileName: File.basename(value),
200
+ fileContent: File.read(value),
201
+ fileType: nil
202
+ }
203
+ end
204
+ end
205
+
206
+ # @private
207
+ def build_expansion_parameters_from_json_content(json_content)
208
+ {
209
+ fileName: 'expansion_parameters.json',
210
+ fileContent: json_content,
211
+ fileType: nil
212
+ }
213
+ end
214
+
121
215
  # @private
122
216
  # Used internally by perform_additional_validation
123
217
  def additional_validations
@@ -622,6 +716,8 @@ module Inferno
622
716
  ],
623
717
  sessionId: @session_id
624
718
  }
719
+ wrapped_resource[:expansionParameters] = expansion_parameters if expansion_parameters
720
+
625
721
  wrapped_resource.to_json
626
722
  end
627
723
  end
@@ -193,14 +193,16 @@ module Inferno
193
193
  inputs[input.name.to_sym] = Entities::Input.new(**input.to_hash)
194
194
  end
195
195
 
196
+ # children_available_inputs walks the entire subtree, so it is computed once here
197
+ # rather than once per input. merge_with_child only mutates its receiver, so the
198
+ # child definitions are safe to reuse for the merge below.
199
+ child_inputs = children_available_inputs(selected_suite_options)
200
+
196
201
  available_inputs.each do |input, current_definition|
197
- child_definition = children_available_inputs(selected_suite_options)[input]
198
- current_definition.merge_with_child(child_definition)
202
+ current_definition.merge_with_child(child_inputs[input])
199
203
  end
200
204
 
201
- available_inputs = children_available_inputs(selected_suite_options).merge(available_inputs)
202
-
203
- order_available_inputs(available_inputs)
205
+ order_available_inputs(child_inputs.merge(available_inputs))
204
206
  end
205
207
  end
206
208
  end
@@ -15,10 +15,14 @@ module Inferno
15
15
  # @param resources [Array<FHIR::Resource>]
16
16
  # @param profile_url [String]
17
17
  # @param validator_name [Symbol] Name of the FHIR Validator that references the IG the profile is in
18
- # @param metadata [Hash] MustSupport Metadata (optional),
19
- # if provided the check will use this instead of re-generating metadata from the profile
18
+ # @param metadata [Inferno::DSL::ProfileMetadata, #must_supports] MustSupport Metadata (optional),
19
+ # if provided the check will use this instead of re-generating metadata from the profile.
20
+ # Must respond to `#must_supports`, returning a Hash with `:elements`, `:extensions`, and
21
+ # `:slices` keys (and optionally `:choices` and `:recursive_elements`) -- the shape produced by
22
+ # {MustSupportMetadataExtractor#must_supports}. {Inferno::DSL::ProfileMetadata} is a base class
23
+ # test kits can subclass to build these objects, eg from generated YAML, instead of hand-rolling one.
20
24
  # @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
21
- # @yield [Metadata] Customize the metadata before running the test
25
+ # @yield [MustSupportMetadataExtractor] Customize the metadata before running the test
22
26
  # @return [Array<String>] List of missing elements
23
27
  def missing_must_support_elements(resources, profile_url, validator_name: :default, metadata: nil,
24
28
  requirement_extension: nil, &)
@@ -199,6 +203,12 @@ module Inferno
199
203
  metadata.must_supports[:extensions]
200
204
  end
201
205
 
206
+ # Names of path segments that are self-referential (eg 'item' in Questionnaire.item.item)
207
+ # and so should be searched at any depth of nesting, not just the literal depth of a path.
208
+ def recursive_element_segments
209
+ Array.wrap(metadata.must_supports[:recursive_elements])
210
+ end
211
+
202
212
  def missing_extensions(resources = [])
203
213
  @missing_extensions ||=
204
214
  must_support_extensions.select do |extension_definition|
@@ -212,7 +222,7 @@ module Inferno
212
222
  normalized_extension_url(extension.url) == expected_url
213
223
  end
214
224
  else
215
- extension = find_a_value_at(resource, path) do |el|
225
+ extension = find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |el|
216
226
  normalized_extension_url(el.url) == expected_url
217
227
  end
218
228
 
@@ -243,9 +253,10 @@ module Inferno
243
253
  ms_extension_urls = must_support_extensions.select { |ex| ex[:path] == "#{raw_path}.extension" }
244
254
  .map { |ex| ex[:url] }
245
255
 
246
- value_found = find_a_value_at(resource, path) do |potential_value|
247
- matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
248
- end
256
+ value_found =
257
+ find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |potential_value|
258
+ matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
259
+ end
249
260
 
250
261
  # Note that false.present? => false, which is why we need to add this extra check
251
262
  value_found.present? || value_found == false
@@ -369,7 +380,7 @@ module Inferno
369
380
  # TODO: there is a lot of similarity
370
381
  # between this and FHIRResourceNavigation.matching_slice?
371
382
  # Can these be combined?
372
- find_a_value_at(resource, path) do |element|
383
+ find_a_value_at(resource, path, recursive_segments: recursive_element_segments) do |element|
373
384
  case discriminator[:type]
374
385
  when 'patternCodeableConcept'
375
386
  find_pattern_codeable_concept_slice(element, discriminator)
@@ -24,16 +24,49 @@ module Inferno
24
24
  self.requirement_extension_url = requirement_extension_url
25
25
  end
26
26
 
27
+ # @return [String] the canonical URL of the profile
28
+ def profile_url
29
+ profile.url
30
+ end
31
+
32
+ # @return [String] the human-readable name of the profile, eg for display purposes.
33
+ # Collapses any runs of repeated whitespace, since profile titles occasionally have them.
34
+ def profile_name
35
+ profile.title&.squeeze(' ')
36
+ end
37
+
38
+ # @return [String] the version of the profile itself (distinct from the IG's own version)
39
+ def profile_version
40
+ profile.version
41
+ end
42
+
27
43
  # Retrieval method for the must support metadata
28
44
  # @return [Hash]
29
45
  def must_supports
30
46
  @must_supports ||= {
31
47
  extensions: must_support_extensions,
32
48
  slices: must_support_slices,
33
- elements: must_support_elements
49
+ elements: must_support_elements,
50
+ recursive_elements: recursive_element_segments
34
51
  }
35
52
  end
36
53
 
54
+ # Names of path segments that are self-referential, ie, they may repeat at
55
+ # arbitrary depth. FHIR marks this with a `contentReference` back to an
56
+ # ancestor element instead of re-declaring the element's children, eg,
57
+ # Questionnaire.item.item has a contentReference of "#Questionnaire.item".
58
+ # A MustSupport flag on an element under such a segment (eg Questionnaire.item.text)
59
+ # should be considered met if it's populated at any depth of nesting
60
+ # (item.text, item.item.text, item.item.item.text, ...), not just the literal depth
61
+ # at which the flag is declared. NOTE: does not currently handle sliced recursive elements.
62
+ # @return [Array<String>]
63
+ def recursive_element_segments
64
+ profile_elements
65
+ .select { |element| element.contentReference.present? }
66
+ .map { |element| element.id.split('.').last }
67
+ .uniq
68
+ end
69
+
37
70
  def by_requirement_extension_only?(element)
38
71
  requirement_extension_url && !element.mustSupport &&
39
72
  element.extension.any? do |extension|
@@ -53,6 +86,7 @@ module Inferno
53
86
  must_support_extension_elements.map do |element|
54
87
  {
55
88
  id: element.id,
89
+ slice_name: extension_slice_name(element.id),
56
90
  path: element.path.gsub("#{resource}.", ''),
57
91
  url: canonical_url_without_version(element.type.first.profile.first),
58
92
  modifier_extension: element.path.end_with?('modifierExtension')
@@ -62,6 +96,15 @@ module Inferno
62
96
  end
63
97
  end
64
98
 
99
+ # The name of the deepest slice in a FHIR ElementDefinition id, eg "us-core-race" for
100
+ # "Patient.extension:us-core-race" or "ombCategory" for
101
+ # "Patient.extension:us-core-race.extension:ombCategory".
102
+ # @param element_id [String]
103
+ # @return [String, nil]
104
+ def extension_slice_name(element_id)
105
+ element_id.split('.').reverse.find { |segment| segment.include?(':') }&.split(':', 2)&.last
106
+ end
107
+
65
108
  def canonical_url_without_version(url)
66
109
  url&.split('|')&.first
67
110
  end