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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +4 -0
- data/config/vocabularies.csv +1 -0
- data/docs/spec/README.md +5 -3
- data/docs/spec/examples/recall-operator.json +6 -3
- data/lib/conceptql/annotation.rb +136 -0
- data/lib/conceptql/behaviors/provenanceable.rb +44 -26
- data/lib/conceptql/cli.rb +0 -6
- data/lib/conceptql/database.rb +74 -4
- data/lib/conceptql/diagram.rb +4 -1
- data/lib/conceptql/diagram_renderer/geometry.rb +6 -1
- data/lib/conceptql/lexicon/lexicon_strategy.rb +23 -12
- data/lib/conceptql/lexicon.rb +24 -35
- data/lib/conceptql/nodifier.rb +16 -0
- data/lib/conceptql/operators/co_reported.rb +1 -0
- data/lib/conceptql/operators/information_periods.rb +7 -3
- data/lib/conceptql/operators/multiple_vocabularies.rb +42 -22
- data/lib/conceptql/operators/operator.rb +38 -8
- data/lib/conceptql/operators/place_of_service_filter.rb +24 -12
- data/lib/conceptql/operators/provenance.rb +11 -11
- data/lib/conceptql/operators/recall.rb +5 -0
- data/lib/conceptql/operators/union.rb +13 -2
- data/lib/conceptql/operators/vocabulary.rb +73 -24
- data/lib/conceptql/query.rb +111 -37
- data/lib/conceptql/scope.rb +1 -18
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +3 -3
- data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +15 -10
- data/lib/conceptql/vocabularies/entry.rb +2 -2
- data/lib/conceptql/vocabularies/read_codes.rb +42 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +3 -0
- metadata +3 -3
- data/lib/conceptql/lexicon/lexicon_gdm.rb +0 -50
- 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
|
|
144
|
-
# {
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
data/lib/conceptql/lexicon.rb
CHANGED
|
@@ -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
|
|
52
|
-
#
|
|
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
|
|
62
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
#
|
|
304
|
-
# or else the lexicon db. When neither has them,
|
|
305
|
-
# for a database-less caller: a real data db raises
|
|
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
|
-
|
|
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
|
|
331
|
+
lexicon_url_searched: !lexicon_db.nil?)
|
|
334
332
|
end
|
|
335
333
|
|
|
336
334
|
def search(role, db)
|
|
337
|
-
missing =
|
|
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
|
data/lib/conceptql/nodifier.rb
CHANGED
|
@@ -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)
|
|
@@ -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
|
|
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 =
|
|
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,
|
|
124
|
-
#
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
148
|
-
|
|
147
|
+
def no_records_in_data?(db, opts = {})
|
|
148
|
+
validate(db, opts)
|
|
149
|
+
@no_records_in_data || false
|
|
150
|
+
end
|
|
149
151
|
|
|
150
|
-
|
|
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
|
|
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
|
|
20
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 +=
|
|
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|
|