conceptql 0.7.0 → 0.8.0

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +4 -0
  4. data/config/vocabularies.csv +1 -0
  5. data/docs/spec/README.md +5 -3
  6. data/docs/spec/examples/recall-operator.json +6 -3
  7. data/lib/conceptql/annotation.rb +136 -0
  8. data/lib/conceptql/behaviors/provenanceable.rb +44 -26
  9. data/lib/conceptql/cli.rb +0 -6
  10. data/lib/conceptql/database.rb +74 -4
  11. data/lib/conceptql/diagram.rb +4 -1
  12. data/lib/conceptql/diagram_renderer/geometry.rb +6 -1
  13. data/lib/conceptql/lexicon/lexicon_strategy.rb +23 -12
  14. data/lib/conceptql/lexicon.rb +24 -35
  15. data/lib/conceptql/nodifier.rb +16 -0
  16. data/lib/conceptql/operators/co_reported.rb +1 -0
  17. data/lib/conceptql/operators/information_periods.rb +7 -3
  18. data/lib/conceptql/operators/multiple_vocabularies.rb +42 -22
  19. data/lib/conceptql/operators/operator.rb +38 -8
  20. data/lib/conceptql/operators/place_of_service_filter.rb +24 -12
  21. data/lib/conceptql/operators/provenance.rb +11 -11
  22. data/lib/conceptql/operators/recall.rb +5 -0
  23. data/lib/conceptql/operators/union.rb +13 -2
  24. data/lib/conceptql/operators/vocabulary.rb +73 -24
  25. data/lib/conceptql/query.rb +111 -37
  26. data/lib/conceptql/scope.rb +1 -18
  27. data/lib/conceptql/version.rb +1 -1
  28. data/lib/conceptql/vocabularies/drg_systems.rb +3 -3
  29. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +15 -10
  30. data/lib/conceptql/vocabularies/entry.rb +2 -2
  31. data/lib/conceptql/vocabularies/read_codes.rb +42 -0
  32. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +3 -0
  33. metadata +3 -3
  34. data/lib/conceptql/lexicon/lexicon_gdm.rb +0 -50
  35. data/lib/conceptql/operators/read.rb +0 -79
@@ -228,8 +228,13 @@ module ConceptQL
228
228
  DOMAIN_LABEL_COLORS.fetch(stream_type, COUNT_LABEL[:fill])
229
229
  end
230
230
 
231
- # formatCount: 1234567 becomes "1,234,567".
231
+ # formatCount: 1234567 becomes "1,234,567". A null count is NA: the
232
+ # data has no records of the operator's vocabulary (bead
233
+ # conceptql-d1bd). The JavaScript renderer has no such case, so the
234
+ # reference of a null count comes from this renderer.
232
235
  def format_count(n)
236
+ return "NA" if n.nil?
237
+
233
238
  JsFormat.string(n).gsub(/\B(?=(\d{3})+(?!\d))/, ",")
234
239
  end
235
240
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "sequel/adapters/mock"
3
4
  require_relative "../vocabularies/formats"
4
5
 
5
6
  module ConceptQL
@@ -140,22 +141,32 @@ module ConceptQL
140
141
  concepts(db, vocabulary_id, codes).select_map(%i[concept_code concept_text])
141
142
  end
142
143
 
143
- # The distinct names of each code that has more than one name, as
144
- # { downcased code => [name, ...] } with the names sorted. A vocabulary
145
- # can reuse a code for a new meaning: DRG 410 meant chemotherapy until
146
- # 2007-09-30 and a biliary tract procedure after that. OHDSI keeps each
147
- # meaning as its own concept row, and the GDM vocabulary keeps both names.
148
- # Names that differ only in case or spacing count as one name.
149
- # A caller can answer "is the concept table missing?" with a block.
144
+ # The distinct names of each of the codes that has more than one name, as
145
+ # { code => [name, ...] } with the names sorted. A vocabulary can reuse a
146
+ # code for a new meaning: DRG 410 meant chemotherapy until 2007-09-30 and
147
+ # a biliary tract procedure after that. OHDSI keeps each meaning as its
148
+ # own concept row. Names that differ only in case or spacing count as one
149
+ # name.
150
+ #
151
+ # A stored code is one of the codes when it matches as code_condition
152
+ # matches: in its own case, unless the vocabulary declares (?i:...). So
153
+ # the Read codes 13g4.00 and 13G4.00 are two codes with one name each, not
154
+ # one code with two names (bead conceptql-qdn9). Each key is a code as the
155
+ # caller gave it. A caller can answer "is the concept table missing?" with
156
+ # a block.
150
157
  def code_meanings(data_db, vocabulary_id, codes)
151
158
  return {} if codes.blank? || db.nil? || db_is_mock?(data_db)
152
159
  return {} if block_given? ? yield : table_is_missing?(data_db)
153
160
 
154
- concepts(db, vocabulary_id, codes)
155
- .select_map(%i[concept_code concept_text])
156
- .group_by { |code, _| code.downcase }
157
- .transform_values { |rows| rows.map(&:last).compact.uniq { |name| name.downcase.split.join(" ") }.sort }
158
- .select { |_, names| names.length > 1 }
161
+ key = case_insensitive?(vocabulary_id) ? :downcase.to_proc : :itself.to_proc
162
+ names = concepts(db, vocabulary_id, codes)
163
+ .select_map(%i[concept_code concept_text])
164
+ .group_by { |code, _| key.call(code) }
165
+ .transform_values { |rows| rows.map(&:last).compact.uniq { |name| name.downcase.split.join(" ") }.sort }
166
+ .select { |_, code_names| code_names.length > 1 }
167
+ Array(codes).each_with_object({}) do |code, meanings|
168
+ meanings[code] = names[key.call(code)] if names.key?(key.call(code))
169
+ end
159
170
  rescue Sequel::DatabaseError
160
171
  {}
161
172
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "forwardable"
4
4
  require_relative "code_case_mismatch_error"
5
- require_relative "lexicon/lexicon_gdm"
6
5
  require_relative "lexicon/lexicon_no_db"
7
6
  require_relative "lexicon/lexicon_ohdsi"
8
7
 
@@ -35,9 +34,6 @@ module ConceptQL
35
34
  :vocabularies,
36
35
  :vocabularies_query
37
36
 
38
- # The key of each lexicon kind in NoVocabularyError::Search#missing_tables.
39
- LEXICON_KINDS = { LexiconOhdsi => :ohdsi, LexiconGDM => :gdm }.freeze
40
-
41
37
  # The cached results keyed on [method, arguments], or nil when caching is
42
38
  # off. Its size is the number of distinct lookups.
43
39
  attr_reader :cache
@@ -48,8 +44,8 @@ module ConceptQL
48
44
  #
49
45
  # ConceptQL::Database raises it (through Lexicon.new) when its data
50
46
  # database is real, which is not nil and not a Sequel mock, and neither
51
- # that database nor the LEXICON_URL database has all the tables of one
52
- # lexicon kind. A database-less Database keeps LexiconNoDB.
47
+ # that database nor the LEXICON_URL database has all the OHDSI
48
+ # vocabulary tables. A database-less Database keeps LexiconNoDB.
53
49
  # MissingVocabularyError (Lexicon.for_dataset) is a subclass, so one
54
50
  # rescue covers both.
55
51
  class NoVocabularyError < StandardError
@@ -58,8 +54,8 @@ module ConceptQL
58
54
  # :dataset (Lexicon.for_dataset). +database+ names it without a user or
59
55
  # a password. +search_path+ is the search path that the unqualified
60
56
  # table names resolved through, or nil where the adapter has none.
61
- # +missing_tables+ maps each lexicon kind (:ohdsi, :gdm) that was
62
- # looked for to its tables that were not found.
57
+ # +missing_tables+ maps the lexicon kind :ohdsi to its tables that were
58
+ # not found.
63
59
  Search = Data.define(:role, :database, :search_path, :missing_tables)
64
60
 
65
61
  ROLES = {
@@ -78,8 +74,7 @@ module ConceptQL
78
74
  # The error for a ConceptQL::Database whose data database is real.
79
75
  # +database_type+ is the data database's, and chooses the advice.
80
76
  # +lexicon_url_searched+ is false when LEXICON_URL is not set.
81
- # +only+ is the LEXICON_*_ONLY variable that narrowed the kinds, or nil.
82
- def self.for_database(searches, database_type:, lexicon_url_searched:, only: nil)
77
+ def self.for_database(searches, database_type:, lexicon_url_searched:)
83
78
  data = searches.first
84
79
  lines = [
85
80
  "ConceptQL::Database found no vocabulary tables for its data database (#{data.database}).",
@@ -89,7 +84,6 @@ module ConceptQL
89
84
  ]
90
85
  lines.concat(searches.map { |search| " - #{describe_search(search)}" })
91
86
  lines << " - LEXICON_URL is not set, so no lexicon database was searched." unless lexicon_url_searched
92
- lines << "#{only} is set, so only the #{only.delete_prefix("LEXICON_").delete_suffix("_ONLY")} tables were looked for." if only
93
87
  lines << "Fix it in one of these ways:"
94
88
  lines.concat(advice(database_type).map { |line| " - #{line}" })
95
89
  new(lines.join("\n"), searches: searches)
@@ -110,7 +104,7 @@ module ConceptQL
110
104
  dataset = "Build the lexicon from the DuckDB dataset itself: " \
111
105
  "ConceptQL::Database.new(db, lexicon: ConceptQL::Lexicon.for_dataset(db))."
112
106
  search_path = "Put the schema that holds the vocabulary tables on the data database's " \
113
- "search path (in the test data, ohdsi_vocabs or gdm_vocabs)."
107
+ "search path (in the test data, ohdsi_vocabs)."
114
108
  lexicon_url = "Set LEXICON_URL to a database that has the vocabulary tables."
115
109
  case database_type
116
110
  when :duckdb then [ dataset, search_path, lexicon_url ]
@@ -221,9 +215,17 @@ module ConceptQL
221
215
 
222
216
  # The strategy's known_codes first asks whether the vocabulary is empty.
223
217
  # It asks through the block, so that question also reaches the cache.
224
- def known_codes(data_db, vocabulary_id, codes, check_case: true)
225
- known = lookup(:known_codes, [ data_db, vocabulary_id, codes ]) do
226
- @strategy.known_codes(data_db, vocabulary_id, codes) { vocabulary_is_empty?(data_db, vocabulary_id) }
218
+ #
219
+ # An empty vocabulary knows every code, unless empty_means_known is
220
+ # false. Then it knows none. The code checks of the operators pass false
221
+ # and apply the 2020 rule (refs #185) through Database#check_codes,
222
+ # because the rule needs both lexicons: it applies only when the
223
+ # reference lexicon lacks the vocabulary too (bead conceptql-dufi).
224
+ def known_codes(data_db, vocabulary_id, codes, check_case: true, empty_means_known: true)
225
+ known = lookup(:known_codes, [ data_db, vocabulary_id, codes, empty_means_known ]) do
226
+ @strategy.known_codes(data_db, vocabulary_id, codes) do
227
+ empty_means_known && vocabulary_is_empty?(data_db, vocabulary_id)
228
+ end
227
229
  end
228
230
  check_case!(data_db, vocabulary_id, Array(codes) - known) if check_case
229
231
  known
@@ -300,19 +302,16 @@ module ConceptQL
300
302
  end
301
303
  private :lookup
302
304
 
303
- # The first lexicon kind whose tables are all in the data db (dataset_db)
304
- # or else the lexicon db. When neither has them, LexiconNoDB, but only
305
- # for a database-less caller: a real data db raises NoVocabularyError.
305
+ # LexiconOhdsi on the data db (dataset_db) when it has all the OHDSI
306
+ # vocabulary tables, or else on the lexicon db. When neither has them,
307
+ # LexiconNoDB, but only for a database-less caller: a real data db raises
308
+ # NoVocabularyError.
306
309
  # A Sequel mock data db is not searched: it answers yes to every
307
310
  # table_exists?, so it would hide the lexicon db behind a vocabulary
308
311
  # that has no rows.
309
312
  def determine_strategy(lexicon_db, dataset_db)
310
313
  [ (dataset_db if real_database?(dataset_db)), lexicon_db ].compact.each do |db|
311
- lexicon_classes.each do |klass|
312
- next unless klass.db_has_all_vocabulary_tables?(db)
313
-
314
- return (klass.new(db))
315
- end
314
+ return LexiconOhdsi.new(db) if LexiconOhdsi.db_has_all_vocabulary_tables?(db)
316
315
  end
317
316
  raise no_vocabulary_error(lexicon_db, dataset_db) if real_database?(dataset_db)
318
317
 
@@ -328,15 +327,12 @@ module ConceptQL
328
327
  def no_vocabulary_error(lexicon_db, dataset_db)
329
328
  searches = [ search(:data_database, dataset_db) ]
330
329
  searches << search(:lexicon_url, lexicon_db) if lexicon_db
331
- only = %w[LEXICON_OHDSI_ONLY LEXICON_GDM_ONLY].find { |name| ENV[name] }
332
330
  NoVocabularyError.for_database(searches, database_type: dataset_db.database_type.to_sym,
333
- lexicon_url_searched: !lexicon_db.nil?, only: only)
331
+ lexicon_url_searched: !lexicon_db.nil?)
334
332
  end
335
333
 
336
334
  def search(role, db)
337
- missing = lexicon_classes.to_h do |klass|
338
- [ LEXICON_KINDS.fetch(klass), klass::VOCABULARY_TABLES.reject { |table| db.table_exists?(table) } ]
339
- end
335
+ missing = { ohdsi: LexiconOhdsi::VOCABULARY_TABLES.reject { |table| db.table_exists?(table) } }
340
336
  NoVocabularyError::Search.new(role: role, database: describe_database(db),
341
337
  search_path: search_path_of(db), missing_tables: missing)
342
338
  end
@@ -368,12 +364,5 @@ module ConceptQL
368
364
  end
369
365
  end
370
366
  private :real_database?, :no_vocabulary_error, :search, :describe_database, :search_path_of
371
-
372
- def lexicon_classes
373
- classes = []
374
- classes << LexiconOhdsi unless ENV["LEXICON_GDM_ONLY"]
375
- classes << LexiconGDM unless ENV["LEXICON_OHDSI_ONLY"]
376
- classes
377
- end
378
367
  end
379
368
  end
@@ -56,6 +56,22 @@ module ConceptQL
56
56
  @operators ||= cdb.operators(@data_model)
57
57
  end
58
58
 
59
+ # The vocabulary ids of the data's clinical codes, as a Set, from the GDM
60
+ # table etl_vocabulary_ids (one row for each vocabulary in
61
+ # clinical_codes.clinical_code_vocabulary_id, written by the ETL). nil
62
+ # when the data has no such table, or when the table has no rows, which
63
+ # says nothing about the data (bead conceptql-d1bd). Only the
64
+ # vocabulary_id column is read, so the older 2-column table works too.
65
+ #
66
+ # Each Query has its own nodifier, so a Query reads the table once,
67
+ # whatever the number of its vocabulary operators.
68
+ def etl_vocabulary_ids(db)
69
+ return @etl_vocabulary_ids if defined?(@etl_vocabulary_ids)
70
+
71
+ ids = cdb.source_table_exists?(db, :etl_vocabulary_ids) ? db[:etl_vocabulary_ids].select_map(:vocabulary_id) : []
72
+ @etl_vocabulary_ids = ids.empty? ? nil : ids.to_set
73
+ end
74
+
59
75
  private
60
76
 
61
77
  def fetch_op(operator)
@@ -31,6 +31,7 @@ module ConceptQL
31
31
  category "Combine Streams"
32
32
  default_query_columns
33
33
  validate_at_least_one_upstream
34
+ validate_at_least_two_upstreams
34
35
  validate_no_arguments
35
36
  require_column :context_id
36
37
 
@@ -41,14 +41,18 @@ module ConceptQL
41
41
  private
42
42
 
43
43
  # A code that names no concept selects no period. A code is known only
44
- # in its own case, as the lookup finds it.
44
+ # in its own case, as the lookup finds it. Database#check_codes sorts
45
+ # the codes that the dataset's lexicon does not know, with the 2020
46
+ # rule, as for a vocabulary operator (bead conceptql-dufi). The check
47
+ # reads only the lexicons, so it needs no data database.
45
48
  def additional_validation(db, opts = {})
46
- return unless add_warnings?(db, opts)
49
+ return unless lexicon_checks?(opts)
47
50
 
48
51
  codes = dm.information_type_codes(arguments)
49
52
  return if codes.empty?
50
53
 
51
- unknown = codes - dm.known_codes(db, dm.information_types_vocab, codes, check_case: false)
54
+ not_present, unknown = nodifier.cdb.check_codes(db, dm.information_types_vocab, codes)
55
+ add_warning(Vocabulary::NOT_PRESENT_IN_DATASET, *not_present) unless not_present.empty?
52
56
  add_warning(UNKNOWN_CODE, *unknown) unless unknown.empty?
53
57
  end
54
58
  end
@@ -120,34 +120,54 @@ module ConceptQL
120
120
  end
121
121
 
122
122
  # The per-code warnings a Vocabulary operator gives, from most to least
123
- # specific (Operator::BAD_FORMATS, Operator::UNKNOWN_CODE). See
124
- # #code_warnings.
125
- CODE_WARNINGS = [ *BAD_FORMATS, UNKNOWN_CODE ].freeze
126
-
123
+ # specific (Operator::BAD_FORMATS, Vocabulary::NOT_PRESENT_IN_DATASET,
124
+ # Operator::UNKNOWN_CODE). This operator checks its codes itself (see
125
+ # #code_warnings), so it drops these warnings of its vocabulary
126
+ # operators.
127
+ CODE_WARNINGS = [ *BAD_FORMATS, Vocabulary::NOT_PRESENT_IN_DATASET, UNKNOWN_CODE ].freeze
128
+
129
+ # The operator checks its codes as one set across its vocabularies
130
+ # (bead conceptql-0h2n). When etl_vocabulary_ids lists none of its
131
+ # vocabularies, it gives the no-records warning and null counts, and
132
+ # its codes get no not-present warning, as a Vocabulary operator's do
133
+ # (bead conceptql-dufi). The other warnings of its vocabulary operators
134
+ # appear only when every one of them gives the warning with the same
135
+ # details (#aggregate_messages).
127
136
  def additional_validation(db, opts = {})
128
- vocab_ops.each { |vo| vo.valid?(db, opts.merge(skip_merged_vocabulary_check: true)) }
137
+ @no_records_in_data = add_warnings?(db, opts) && vocab_ops.all? { |vo| vo.absent_from_data?(db) }
138
+ inner_opts = opts.merge(skip_merged_vocabulary_check: true)
139
+ vocab_ops.each { |vo| vo.valid?(db, inner_opts) }
129
140
 
130
141
  @errors += aggregate_messages(vocab_ops.map(&:errors))
131
142
  @warnings += aggregate_messages(vocab_ops.map { |vo| vo.warnings.reject { |w| CODE_WARNINGS.include?(w.first) } })
132
- @warnings += code_warnings
133
- end
134
-
135
- # A code gets a warning only when every vocabulary rejects it, for any
136
- # reason. It is an improperly formatted code only when every vocabulary
137
- # says so. When at least one vocabulary accepts its format but does not
138
- # know it, it is an unknown code. (12345 is badly formatted for HCPCS but
139
- # a well-formed, unknown CPT4 code, so it is unknown.)
140
- def code_warnings
141
- verdicts = vocab_ops.map do |vo|
142
- vo.warnings.each_with_object({}) do |(message, *codes), verdict|
143
- codes.each { |code| verdict[code] ||= message } if CODE_WARNINGS.include?(message)
144
- end
145
- end
143
+ add_warning(Vocabulary::NO_RECORDS_IN_DATA) if @no_records_in_data
144
+ @warnings += code_warnings(db, opts)
145
+ end
146
146
 
147
- rejected = arguments.uniq.select { |code| verdicts.all? { |verdict| verdict.key?(code) } }
148
- bad_format, unknown = rejected.partition { |code| verdicts.all? { |verdict| BAD_FORMATS.include?(verdict[code]) } }
147
+ def no_records_in_data?(db, opts = {})
148
+ validate(db, opts)
149
+ @no_records_in_data || false
150
+ end
149
151
 
150
- [ [ BAD_FORMAT, *bad_format ], [ UNKNOWN_CODE, *unknown ] ].reject { |warning| warning.length == 1 }
152
+ # A code is improperly formatted when every vocabulary rejects its
153
+ # format. (12345 is badly formatted for HCPCS but has the format of
154
+ # CPT4, so it is checked.) Database#check_codes sorts the other codes
155
+ # across all of the vocabularies: a code is known when the Database's
156
+ # lexicon has it in any of them, and the 2020 rule applies only when no
157
+ # lexicon has a concept of any of them. With no records of the
158
+ # vocabularies in the data, the not-present warning is not given. Each
159
+ # warning lists every code.
160
+ def code_warnings(db, opts = {})
161
+ return [] if arguments.include?("*")
162
+
163
+ codes = arguments.uniq
164
+ bad_format = codes.select { |code| vocab_ops.all? { |vo| vo.bad_arguments.include?(code) } }
165
+ checked = codes - bad_format
166
+ not_present, unknown = lexicon_checks?(opts) ? nodifier.cdb.check_codes(db, vocab_ops.map(&:vocabulary_id), checked) : [ [], [] ]
167
+ not_present = [] if @no_records_in_data
168
+
169
+ [ [ BAD_FORMAT, *bad_format ], [ Vocabulary::NOT_PRESENT_IN_DATASET, *not_present ], [ UNKNOWN_CODE, *unknown ] ]
170
+ .reject { |warning| warning.length == 1 }
151
171
  end
152
172
 
153
173
  def aggregate_messages(message_sets)
@@ -48,6 +48,7 @@ module ConceptQL
48
48
  NDC_BAD_FORMAT = "#{BAD_FORMAT} (claims use 11-digit package NDCs, so it matches no rows)"
49
49
  BAD_FORMATS = [ BAD_FORMAT, NDC_BAD_FORMAT ].freeze
50
50
  UNKNOWN_CODE = "unknown code(s)"
51
+ ONE_UPSTREAM = "has only one upstream"
51
52
 
52
53
  option :label, type: :string,
53
54
  meaning: "A short description of the operator's records. The operator writes it into the label column of each of its records, and a recall operator selects those records by it. Each label in a statement must be different."
@@ -80,6 +81,7 @@ module ConceptQL
80
81
  no_upstreams
81
82
  one_upstream
82
83
  at_least_one_upstream
84
+ at_least_two_upstreams
83
85
  at_most_one_upstream
84
86
  no_arguments
85
87
  one_argument
@@ -171,7 +173,6 @@ module ConceptQL
171
173
  def annotate(db, opts = {})
172
174
  return @annotate if defined?(@annotate)
173
175
 
174
- scope_key = options[:id] || op_name
175
176
  annotation = {}
176
177
  counts = (annotation[:counts] ||= {})
177
178
  metadata = { annotation: annotation }
@@ -181,24 +182,23 @@ module ConceptQL
181
182
  end
182
183
  res = [ op_name, *annotate_values(db, opts) ]
183
184
 
185
+ # An operator of a vocabulary that the data does not contain has null
186
+ # counts, and so does an operator whose inputs all have null counts.
187
+ # A 0 would say that no patient has the codes (bead conceptql-d1bd).
188
+ no_records = no_records_in_data?(db, opts)
184
189
  if countable?(db, opts)
185
- counts.merge!(scope.one_query_counts&.for(self) || domain_counts(db))
190
+ counts.merge!(scope.one_query_counts&.for(self) || domain_counts(db)) unless no_records
186
191
  elsif !errors.empty?
187
192
  annotation[:errors] = errors
188
- scope.add_errors(scope_key, errors)
189
193
  end
190
194
 
191
- domains(db).each do |domain|
192
- cur_counts = counts[domain] ||= { rows: 0, n: 0 }
193
- scope.add_counts(scope_key, domain, cur_counts)
194
- end
195
+ domains(db).each { |domain| counts[domain] ||= no_records ? { rows: nil, n: nil } : { rows: 0, n: 0 } }
195
196
  # The database returns its groups in no fixed order, and the zero
196
197
  # counts above come last. Sort them, so each run gives the same order.
197
198
  annotation[:counts] = counts.sort.to_h
198
199
 
199
200
  if defined?(@warnings) && !warnings.empty?
200
201
  annotation[:warnings] = warnings
201
- scope.add_warnings(scope_key, warnings.dup)
202
202
  end
203
203
 
204
204
  if res.last.is_a?(Hash)
@@ -340,6 +340,15 @@ module ConceptQL
340
340
  upstreams_valid?(db, opts) && scope.valid? && include_counts?(db, opts)
341
341
  end
342
342
 
343
+ # Whether the data has no records for this operator to count, so its
344
+ # counts are null. A Vocabulary operator decides for itself. Any other
345
+ # operator has none when every one of its inputs has none. An operator
346
+ # with one real input keeps its real counts (bead conceptql-d1bd).
347
+ def no_records_in_data?(db, opts = {})
348
+ inputs = all_upstreams.compact
349
+ !inputs.empty? && inputs.all? { |input| input.no_records_in_data?(db, opts) }
350
+ end
351
+
343
352
  # Rows and distinct people of each criterion_domain in this operator's
344
353
  # output, with a query for this operator alone.
345
354
  def domain_counts(db)
@@ -564,6 +573,14 @@ module ConceptQL
564
573
  add_error("has no upstream") if @upstreams.empty?
565
574
  end
566
575
 
576
+ # A warning, not an error: an operator that combines its upstreams
577
+ # passes the records of one upstream through unchanged, which is almost
578
+ # never what the author meant. Zero upstreams is the error of
579
+ # validate_at_least_one_upstream, so declare both.
580
+ def validate_at_least_two_upstreams
581
+ add_warning(ONE_UPSTREAM) if @upstreams.length == 1
582
+ end
583
+
567
584
  def validate_no_arguments
568
585
  add_error("has arguments", @arguments) unless @arguments.empty?
569
586
  end
@@ -617,6 +634,10 @@ module ConceptQL
617
634
  end
618
635
  end
619
636
 
637
+ # MultipleVocabularies reads the bad codes of each of its vocabulary
638
+ # operators.
639
+ protected :bad_arguments
640
+
620
641
  # Only Vocabulary operators carry a code format (Vocabulary#code_regexp)
621
642
  def code_regexp
622
643
  nil
@@ -641,6 +662,15 @@ module ConceptQL
641
662
  @errors.empty? && !no_db?(db, opts)
642
663
  end
643
664
 
665
+ # Whether to run the checks that read only the lexicon, such as the
666
+ # unknown-code check. They need no data database, so they also run for
667
+ # a Database whose data database is nil or a Sequel mock but that has a
668
+ # lexicon (the Jigsaw editor, bead conceptql-0ubo). skip_db turns them
669
+ # off, as it turns off add_warnings?.
670
+ def lexicon_checks?(opts = {})
671
+ @errors.empty? && dm.lexicon.strategy != :no_db && !skip_db?(opts)
672
+ end
673
+
644
674
  def add_error(*args)
645
675
  errors << args
646
676
  end
@@ -16,8 +16,8 @@ module ConceptQL
16
16
  #
17
17
  # Multiple places of service can be specified at once. Each code becomes
18
18
  # a concept ID through the lexicon, so a code that names no concept can
19
- # match no record. Such a code is an error, as an unrecognized
20
- # provenance keyword is.
19
+ # match no record. Such a code gets a warning, as an unknown provenance
20
+ # keyword does.
21
21
  #
22
22
  # The code "*" keeps a record whose place of service is any concept of
23
23
  # the vocabulary, as provenance's "*" keeps any provenance concept. A
@@ -38,7 +38,7 @@ module ConceptQL
38
38
  argument :places_of_service, type: :codelist, vocab: VOCABULARY_ID,
39
39
  meaning: "One or more CMS Place of Service codes, such as 21 (inpatient hospital) or 22 (outpatient hospital). " \
40
40
  "The code * matches a record whose place of service is any code of that vocabulary. " \
41
- "An unknown code makes the statement invalid."
41
+ "An unknown code gets a warning and matches no record."
42
42
 
43
43
  # The test data has no place of service, so the example returns no rows
44
44
  example "Diabetes diagnoses (ICD-9 250.00) with the place of service outpatient hospital (22)",
@@ -60,22 +60,34 @@ module ConceptQL
60
60
  private
61
61
 
62
62
  # With no codes, the lookup returns every concept of the vocabulary.
63
- # The codes come from the statement, whose validation reports an
63
+ # The codes come from the statement, whose validation warns about an
64
64
  # unknown code, so the lookup does not raise CodeCaseMismatchError.
65
65
  def place_of_service_concept_ids(db)
66
66
  codes = arguments.map(&:to_s)
67
67
  dm.concept_ids(db, VOCABULARY_ID, codes.include?(ANY) ? [] : codes, check_case: false)
68
68
  end
69
69
 
70
- # With no lexicon there are no codes to check against, and every code
71
- # would be reported. Provenance skips its check in the same situation.
72
- # A code is known only in its own case, as the lookup finds it.
73
- def additional_validation(db, _opts = {})
74
- return if dm.lexicon.strategy == :no_db
70
+ # A code that the dataset's lexicon does not know is a warning, as an
71
+ # unknown code of a vocabulary operator is: "code(s) not present in
72
+ # this dataset" when the reference lexicon knows it, and else "unknown
73
+ # code(s)" (bead conceptql-amuu). A code is known only in its own case,
74
+ # as the lookup finds it. The check reads only the lexicons, so it
75
+ # needs no data database.
76
+ def additional_validation(db, opts = {})
77
+ return unless lexicon_checks?(opts)
75
78
 
76
- known = dm.concept_codes(db, VOCABULARY_ID)
77
- unknown = arguments.reject { |code| code.to_s == ANY || known.include?(code.to_s) }
78
- add_error(UNKNOWN_CODE, *unknown) unless unknown.empty?
79
+ codes = arguments.map(&:to_s).uniq - [ ANY ]
80
+ return if codes.empty?
81
+
82
+ not_present, unknown = nodifier.cdb.check_codes(db, VOCABULARY_ID, codes)
83
+ add_warning(Vocabulary::NOT_PRESENT_IN_DATASET, *as_written(not_present)) unless not_present.empty?
84
+ add_warning(UNKNOWN_CODE, *as_written(unknown)) unless unknown.empty?
85
+ end
86
+
87
+ # The arguments whose text is one of the codes, as written and in the
88
+ # order written. count_person_with_co_reported writes a code as a number.
89
+ def as_written(codes)
90
+ arguments.select { |code| codes.include?(code.to_s) }.uniq
79
91
  end
80
92
  end
81
93
  end
@@ -33,7 +33,7 @@ module ConceptQL
33
33
  "A keyword also matches the provenances under it, so inpatient includes snf. " \
34
34
  "A keyword of the form file:code, such as inpatient:primary, needs both the file and the code provenance to match. " \
35
35
  "The keyword * matches a record that has any provenance. " \
36
- "A statement with no keyword, or with an unknown keyword, is invalid."
36
+ "A statement with no keyword is invalid. An unknown keyword gets a warning and matches no record."
37
37
 
38
38
  example "Diabetes diagnoses (ICD-9 250.00) from inpatient files",
39
39
  [ "provenance", "inpatient", [ "icd9", "250.00" ] ]
@@ -55,18 +55,18 @@ module ConceptQL
55
55
 
56
56
  private
57
57
 
58
- def additional_validation(db, _opts = {})
58
+ # A keyword that the dataset's lexicon does not know is a warning, as
59
+ # an unknown code of a vocabulary operator is: "code(s) not present in
60
+ # this dataset" when the reference lexicon knows it, and else "unknown
61
+ # code(s)" (bead conceptql-amuu). Such a keyword selects no record. The
62
+ # check reads only the lexicons, so it needs no data database.
63
+ def additional_validation(db, opts = {})
59
64
  return add_error('no provenance keywords; use "*" for any provenance') if arguments.empty?
65
+ return unless lexicon_checks?(opts)
60
66
 
61
- # With no lexicon there are no provenance concepts to check against,
62
- # and every keyword would be reported as unrecognized. Vocabulary
63
- # operators skip their unknown-code check in the same situation.
64
- return if dm.lexicon.strategy == :no_db
65
-
66
- bad_keywords = find_bad_keywords(db, arguments)
67
- return unless bad_keywords.present?
68
-
69
- add_error("unrecognized keywords", *bad_keywords.uniq)
67
+ not_present, unknown = check_keywords(db, arguments)
68
+ add_warning(Vocabulary::NOT_PRESENT_IN_DATASET, *not_present) unless not_present.empty?
69
+ add_warning(UNKNOWN_CODE, *unknown) unless unknown.empty?
70
70
  end
71
71
  end
72
72
  end
@@ -79,6 +79,11 @@ module ConceptQL
79
79
  nodifier.scope.fetch_operator(source)
80
80
  end
81
81
 
82
+ # The input of a recall is the labeled operator.
83
+ def no_records_in_data?(db, opts = {})
84
+ valid?(db, opts) && original.no_records_in_data?(db, opts)
85
+ end
86
+
82
87
  def code_list(db)
83
88
  original.code_list(db)
84
89
  end
@@ -10,12 +10,22 @@ module ConceptQL
10
10
  class Combiner
11
11
  attr_reader :combinables
12
12
 
13
+ # The vocabulary leaves that one scan can select, in groups: the
14
+ # leaves of one domain and one column family. The scan takes its
15
+ # columns from its first leaf, so a leaf with the lab columns (read,
16
+ # whose domain is condition_occurrence) gets its own scan, and an icd9
17
+ # leaf beside it keeps the default columns (bead conceptql-qdn9).
18
+ def self.scans(combinables)
19
+ combinables.group_by { |combo| [ combo.domain, combo.column_family ] }.values
20
+ end
21
+
13
22
  def initialize(combinables)
14
23
  @combinables = combinables
15
24
  end
16
25
 
17
26
  def queries(db)
18
- combinables.group_by(&:domain).map do |domain, combos|
27
+ self.class.scans(combinables).map do |combos|
28
+ domain = combos.first.domain
19
29
  include_uuid = combos.any? { |c| c.options[:uuid] }
20
30
  criteria = combos.map do |combo|
21
31
  combo.filter_clause(db)
@@ -71,6 +81,7 @@ module ConceptQL
71
81
  category "Combine Streams"
72
82
  default_query_columns
73
83
  validate_at_least_one_upstream
84
+ validate_at_least_two_upstreams
74
85
  validate_no_arguments
75
86
 
76
87
  def query(db)
@@ -112,7 +123,7 @@ module ConceptQL
112
123
  def disjoint_upstreams?
113
124
  combinables, individuals = upstreams.partition { |upstream| upstream.is_a?(Vocabulary) }
114
125
  queries = individuals.map { |upstream| branches_of(upstream) }
115
- queries += combinables.group_by(&:domain).values.map { |combos| combos.flat_map { |c| branches_of(c) } }
126
+ queries += Combiner.scans(combinables).map { |combos| combos.flat_map { |c| branches_of(c) } }
116
127
  return false unless queries.all?
117
128
 
118
129
  queries.combination(2).all? do |one, other|