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
@@ -6,12 +6,13 @@ module ConceptQL
6
6
  module Operators
7
7
  class Vocabulary < Operator
8
8
  # The vocabulary operators share one reference section (spec_id
9
- # vocabulary-operator): each generated vocabulary operator, read and
10
- # the multiple vocabulary operators. So the section's texts are written
11
- # on this class: `returns`, the meaning: of the codes argument, and each
12
- # `example`. An operator of the section extends this module and serves
13
- # the returns text, and only the examples whose statement starts with
14
- # its own name, so no operator shows an example of another one.
9
+ # vocabulary-operator): each generated vocabulary operator (read is
10
+ # one) and the multiple vocabulary operators. So the section's texts
11
+ # are written on this class: `returns`, the meaning: of the codes
12
+ # argument, and each `example`. An operator of the section extends this
13
+ # module and serves the returns text, and only the examples whose
14
+ # statement starts with its own name, so no operator shows an example
15
+ # of another one.
15
16
  module SharedDocumentation
16
17
  def get_returns
17
18
  super || Vocabulary.returns
@@ -79,32 +80,46 @@ module ConceptQL
79
80
  "HCPCS" => { vocabulary_id: "CPT4_HCPCS", operator: "cpt_or_hcpcs" }
80
81
  }.freeze
81
82
  NO_RECORDS_IN_DATA = "the data has no records of this vocabulary"
83
+ NOT_PRESENT_IN_DATASET = "code(s) not present in this dataset"
82
84
 
83
85
  # MultipleVocabularies passes skip_merged_vocabulary_check to its
84
86
  # vocabulary operators. It reads the merged vocabulary itself, and it
85
- # would drop this warning anyway (see #warn_about_merged_vocabulary).
87
+ # would drop these warnings anyway (see #warn_about_merged_vocabulary).
88
+ # The merged-vocabulary and the no-records checks read the data, so
89
+ # they need a data database. The code checks read only the lexicon.
90
+ # When the merged-vocabulary warning fires, it names the operator to
91
+ # use, so the no-records check does not add a second warning with the
92
+ # same text.
93
+ #
94
+ # Database#check_codes sorts the codes that the Database's lexicon does
95
+ # not know into "code(s) not present in this dataset" and "unknown
96
+ # code(s)", and applies the 2020 rule. Each warning lists every code.
97
+ # When the data has no records of the vocabulary at all, the no-records
98
+ # warning already says that every code is absent, so the not-present
99
+ # warning is not given. The unknown-code and reused-code warnings still
100
+ # are, so a typo is not hidden (bead conceptql-dufi).
86
101
  def additional_validation(db, opts = {})
87
- return unless add_warnings?(db, opts)
88
-
89
- warn_about_merged_vocabulary(db) unless opts[:skip_merged_vocabulary_check]
102
+ if add_warnings?(db, opts) && !opts[:skip_merged_vocabulary_check]
103
+ merged_warning = warn_about_merged_vocabulary(db)
104
+ check_for_records(db, warn: !merged_warning)
105
+ end
106
+ return unless lexicon_checks?(opts)
90
107
  return if select_all?
91
108
 
92
- args = arguments.dup
93
- args -= bad_arguments
94
-
95
- # A statement warns about a code in another case than the stored one,
96
- # so its lookups do not raise CodeCaseMismatchError (bead conceptql-rz8s).
97
- missing_args = args - dm.known_codes(db, vocabulary_id, args, check_case: false)
98
- add_warning(UNKNOWN_CODE, *missing_args) unless missing_args.empty?
109
+ args = arguments - bad_arguments
110
+ not_present, unknown = nodifier.cdb.check_codes(db, vocabulary_id, args)
111
+ add_warning(NOT_PRESENT_IN_DATASET, *not_present) unless not_present.empty? || @no_records_in_data
112
+ add_warning(UNKNOWN_CODE, *unknown) unless unknown.empty?
99
113
 
100
- warn_about_reused_codes(db, args - missing_args)
114
+ warn_about_reused_codes(db, args - not_present - unknown)
101
115
  end
102
116
 
103
117
  # Warns when the data has no record of this vocabulary but has records
104
118
  # of the merged vocabulary that holds its codes, and the Database's
105
119
  # lexicon carries that merged vocabulary (bead conceptql-k5ho). The
106
120
  # warning names the operator to use instead. The test fixture keeps
107
- # CPT4 and HCPCS rows, so it never shows this warning.
121
+ # CPT4 and HCPCS rows, so it never shows this warning. It returns the
122
+ # warnings when it adds one, and nil otherwise.
108
123
  def warn_about_merged_vocabulary(db)
109
124
  merged = MERGED_VOCABULARIES[vocabulary_id]
110
125
  return unless merged && nodifier.operators.key?(merged[:vocabulary_id].downcase)
@@ -117,6 +132,40 @@ module ConceptQL
117
132
  "the data stores #{vocabulary_id} codes as #{merged[:vocabulary_id]}, so use #{merged[:operator]}")
118
133
  end
119
134
 
135
+ # Warns when etl_vocabulary_ids lists the vocabularies of the data's
136
+ # clinical codes and this vocabulary is not among them (bead
137
+ # conceptql-d1bd). A count of 0 would then say that no patient has the
138
+ # codes, which the data cannot show, so #annotate reports null counts.
139
+ # With warn: false (the merged-vocabulary warning fired, with the same
140
+ # text), the counts are null and no second warning is added.
141
+ # etl_vocabulary_ids covers the clinical codes only, so an operator
142
+ # that reads another table (death type, observation period type) is
143
+ # not checked. Neither is a dataset without the table.
144
+ def check_for_records(db, warn: true)
145
+ return unless absent_from_data?(db)
146
+
147
+ @no_records_in_data = true
148
+ add_warning(NO_RECORDS_IN_DATA) if warn
149
+ end
150
+
151
+ # Whether etl_vocabulary_ids lists the vocabularies of the data's
152
+ # clinical codes and this vocabulary is not among them. See
153
+ # #check_for_records. MultipleVocabularies asks each of its vocabulary
154
+ # operators.
155
+ def absent_from_data?(db)
156
+ return false unless criterion_table == dm.table_by_domain(:clinical_codes)
157
+ return false unless (ids = nodifier.etl_vocabulary_ids(db))
158
+
159
+ !ids.include?(vocabulary_id)
160
+ end
161
+
162
+ # Whether the data has no records of this vocabulary at all, as
163
+ # #check_for_records found.
164
+ def no_records_in_data?(db, opts = {})
165
+ validate(db, opts)
166
+ @no_records_in_data || false
167
+ end
168
+
120
169
  # A code that the vocabulary reused for a new meaning selects records of
121
170
  # both meanings: DRG 410 selects chemotherapy stays before 2007-10-01 and
122
171
  # biliary tract procedures after it (conceptql-ttk). Each item names the
@@ -125,7 +174,7 @@ module ConceptQL
125
174
  def warn_about_reused_codes(db, codes)
126
175
  meanings = dm.code_meanings(db, vocabulary_id, codes, check_case: false)
127
176
  reused = codes.filter_map do |code|
128
- names = meanings[code.downcase]
177
+ names = meanings[code]
129
178
  "#{code}: #{names.join(' | ')}" if names
130
179
  end
131
180
  add_warning("code(s) with more than one meaning", *reused) unless reused.empty?
@@ -157,6 +206,10 @@ module ConceptQL
157
206
  { clinical_code_vocabulary_id: vocabulary_id }
158
207
  end
159
208
 
209
+ def vocabulary_id
210
+ @vocabulary_id ||= translated_vocabulary_id
211
+ end
212
+
160
213
  private
161
214
 
162
215
  def query(db)
@@ -222,10 +275,6 @@ module ConceptQL
222
275
  vocab_entry.omopv5_id == "NDC" ? NDC_BAD_FORMAT : BAD_FORMAT
223
276
  end
224
277
 
225
- def vocabulary_id
226
- @vocabulary_id ||= translated_vocabulary_id
227
- end
228
-
229
278
  def translated_vocabulary_id
230
279
  vocab_entry.omopv5_vocabulary_id || op_name
231
280
  end
@@ -5,6 +5,7 @@ require "forwardable"
5
5
  require_relative "scope"
6
6
  require_relative "nodifier"
7
7
  require_relative "one_query_counts"
8
+ require_relative "annotation"
8
9
  require "sequel/sqlformat"
9
10
 
10
11
  module ConceptQL
@@ -29,9 +30,10 @@ module ConceptQL
29
30
  # errors. An invalid statement can crash while it builds, or build SQL that
30
31
  # silently returns fewer rows. Warnings do not raise.
31
32
  #
32
- # #errors is the errors hash that #scope_annotate reports (operator name =>
33
- # list of [message, *details]), so it holds the errors that annotate shows.
34
- # #statement is the statement that the Query holds.
33
+ # #errors is the list of every error that annotate shows, in the form of
34
+ # ConceptQL::Annotation#errors ({ operator:, id:, message:, details: }), so
35
+ # two operators with the same name keep their own errors. #statement is the
36
+ # statement that the Query holds.
35
37
  class InvalidStatementError < Query::QueryError
36
38
  attr_reader :statement, :errors
37
39
 
@@ -39,15 +41,15 @@ module ConceptQL
39
41
  @statement = statement
40
42
  @errors = errors
41
43
  super([ "Invalid ConceptQL statement. Validation errors:",
42
- *errors.flat_map { |op, op_errors| op_errors.map { |e| " #{op}: #{describe(e)}" } },
44
+ *errors.map { |error| " #{error[:operator]}: #{describe(error)}" },
43
45
  "Statement: #{statement.inspect}" ].join("\n"))
44
46
  end
45
47
 
46
48
  private
47
49
 
48
50
  def describe(error)
49
- message, *details = error
50
- details.empty? ? message.to_s : "#{message} (#{details.flatten.map(&:to_s).join(', ')})"
51
+ details = error[:details]
52
+ details.empty? ? error[:message].to_s : "#{error[:message]} (#{details.flatten.map(&:to_s).join(', ')})"
51
53
  end
52
54
  end
53
55
 
@@ -63,6 +65,7 @@ module ConceptQL
63
65
 
64
66
  def initialize(cdb, statement, opts = {})
65
67
  @cdb = cdb
68
+ @given_statement = statement
66
69
  @statement, opts = extract_statement(statement, opts)
67
70
  @opts = opts
68
71
  opts[:algorithm_fetcher] ||= proc do |alg|
@@ -131,21 +134,43 @@ module ConceptQL
131
134
  raise QueryError, "Failed to generate SQL for #{statement.inspect}"
132
135
  end
133
136
 
134
- # With counts on, DuckDB and Postgres count every operator with one SQL
135
- # statement (OneQueryCounts). Operators memoize #annotate, so a later call
136
- # with other opts returns the first result.
137
+ # The statement, with an annotation hash in each operator: its effective
138
+ # id (see Annotation.assign_ids), its counts, errors and warnings. With
139
+ # counts on, DuckDB and Postgres count every operator with one SQL
140
+ # statement (OneQueryCounts). ConceptQL::Annotation reads the result.
137
141
  #
138
- # The errors of the window table's own statement are reported under
139
- # "window_table" (see #window_table_errors). Then nothing is counted,
140
- # because every count joins the window table, and building its SQL raises.
142
+ # The Query keeps one result for each opts hash, so a repeated call with
143
+ # the same opts annotates once. Operators memoize #annotate and write into
144
+ # their own options, so each opts hash gets a fresh operator tree, and a
145
+ # call with other opts gets its own correct result.
146
+ #
147
+ # A window statement keeps its window operator at the top, and a window
148
+ # table that is a ConceptQL statement is annotated in place, in the
149
+ # window_table option of the window operator, so each of its errors stays
150
+ # on its own operator. When the window table has errors, the outer
151
+ # statement is not counted, because every count joins the window table,
152
+ # and building its SQL raises.
141
153
  def annotate(opts = {})
142
- window_errors = window_table_errors
143
- unless window_errors.empty?
144
- nodifier.scope.add_errors("window_table", window_errors)
145
- opts = opts.merge(skip_counts: true)
146
- end
147
- nodifier.scope.one_query_counts = OneQueryCounts.new(operator, db, opts)
148
- operator.annotate(db, opts)
154
+ annotated_tree(opts).annotated
155
+ end
156
+
157
+ # The errors of the statement, as ConceptQL::Annotation#errors gives them.
158
+ # They come from the kept annotation with skip_counts: true, so no count
159
+ # SQL runs.
160
+ def errors
161
+ annotation.errors
162
+ end
163
+
164
+ # The warnings of the statement, as ConceptQL::Annotation#warnings gives
165
+ # them, from the same kept annotation as #errors.
166
+ def warnings
167
+ annotation.warnings
168
+ end
169
+
170
+ # True when no operator has an error. Warnings do not make a statement
171
+ # invalid.
172
+ def valid?
173
+ annotation.valid?
149
174
  end
150
175
 
151
176
  def accept(visitor, _opts = {})
@@ -158,11 +183,6 @@ module ConceptQL
158
183
  visitor.results
159
184
  end
160
185
 
161
- def scope_annotate(opts = {})
162
- annotate(opts)
163
- nodifier.scope.annotation
164
- end
165
-
166
186
  def domains
167
187
  operator.domains(db)
168
188
  end
@@ -200,21 +220,55 @@ module ConceptQL
200
220
  operator.code_list(db).uniq
201
221
  end
202
222
 
203
- # The errors that #scope_annotate reports, the same check that callers use
204
- # to decide whether a statement is valid. A fresh query runs it, because
205
- # operators memoize #annotate and #validate, and this query's later
206
- # #annotate calls must not get those results.
223
+ # Every error of the statement, the check that callers use to decide
224
+ # whether a statement is valid. It is #errors: it reads the kept
225
+ # annotation with skip_counts: true, so it never runs count SQL. #sql
226
+ # calls it.
207
227
  def validation_errors
208
- Query.new(cdb, statement, @opts).scope_annotate(skip_counts: true)[:errors]
228
+ errors
209
229
  end
210
230
 
211
231
  def drop_temp_tables(opts = {})
212
232
  query.drop_temp_tables(opts)
213
233
  end
214
234
 
235
+ protected
236
+
237
+ attr_reader :nodifier, :annotated
238
+
239
+ # Annotates this Query's own operator tree once. Only #annotated_tree
240
+ # calls it, on a fresh Query.
241
+ def annotate_own_tree(opts)
242
+ @annotated = Annotation.assign_ids(annotate_without_ids(opts))
243
+ end
244
+
245
+ # The annotated statement before Annotation.assign_ids, so that the ids
246
+ # of a window table statement are assigned with the ids of the statement
247
+ # around it. It annotates this Query's own operator tree, so only a fresh
248
+ # Query calls it.
249
+ def annotate_without_ids(opts)
250
+ opts = opts.merge(skip_counts: true) unless window_table_errors.empty?
251
+ nodifier.scope.one_query_counts = OneQueryCounts.new(operator, db, opts)
252
+ with_window(operator.annotate(db, opts), opts)
253
+ end
254
+
215
255
  private
216
256
 
217
- attr_reader :cdb, :nodifier
257
+ attr_reader :cdb
258
+
259
+ # A fresh Query of the given statement (with its window operator) for
260
+ # each opts hash, with its own operator tree and scope, that has annotated
261
+ # its tree with those opts. This Query's own tree is never annotated, so
262
+ # #query and #sql do not depend on annotate calls.
263
+ def annotated_tree(opts)
264
+ (@annotated_trees ||= {})[opts.dup] ||= Query.new(cdb, @given_statement, @opts).tap do |tree|
265
+ tree.annotate_own_tree(opts)
266
+ end
267
+ end
268
+
269
+ def annotation
270
+ Annotation.new(annotate(skip_counts: true))
271
+ end
218
272
 
219
273
  # Raises InvalidStatementError if #validation_errors is not empty. The
220
274
  # statement and the opts of a Query do not change, so it checks once.
@@ -229,20 +283,39 @@ module ConceptQL
229
283
  @valid = true
230
284
  end
231
285
 
286
+ # The window table of the scope, as Window::Table reads it: a table name
287
+ # or a ConceptQL statement (an Array)
288
+ def window_table
289
+ scope_opts = nodifier.scope.opts
290
+ scope_opts.merge(scope_opts[:window_opts] || {})[:window_table]
291
+ end
292
+
232
293
  # The validation errors of the window table's statement, when the window
233
- # table is a ConceptQL statement (an Array, as Window::Table reads it), as
234
- # one list of [message, *details]. Each message starts with the name of
235
- # the operator that reported it. A table name has no errors.
294
+ # table is a ConceptQL statement, as one list of [message, *details]. Each
295
+ # message starts with the name of the operator that reported it. A table
296
+ # name has no errors.
236
297
  def window_table_errors
237
- scope_opts = nodifier.scope.opts
238
- window_table = scope_opts.merge(scope_opts[:window_opts] || {})[:window_table]
239
298
  return [] unless window_table.is_a?(Array)
240
299
 
241
- cdb.query(window_table).validation_errors.flat_map do |op, op_errors|
242
- op_errors.map { |message, *details| [ "#{op}: #{message}", *details ] }
300
+ cdb.query(window_table).validation_errors.map do |error|
301
+ [ "#{error[:operator]}: #{error[:message]}", *error[:details] ]
243
302
  end
244
303
  end
245
304
 
305
+ # The annotated statement inside its window operator, when the statement
306
+ # is a window statement or the scope's window table is a statement. The
307
+ # window operator has the options of the statement's window and an
308
+ # annotation hash, which holds only its id. A window table statement is
309
+ # annotated in place, with the same opts.
310
+ def with_window(annotated, opts)
311
+ table = window_table
312
+ return annotated unless @window_options || table.is_a?(Array)
313
+
314
+ options = (@window_options || {}).dup
315
+ options[:window_table] = cdb.query(table).annotate_without_ids(opts) if table.is_a?(Array)
316
+ [ "window", annotated, options.merge(annotation: {}) ]
317
+ end
318
+
246
319
  def extract_statement(stmt, opts)
247
320
  if !stmt.is_a?(Array)
248
321
  raise "Improper ConceptQL statement: Expected an Array, got a #{stmt.class}"
@@ -254,6 +327,7 @@ module ConceptQL
254
327
 
255
328
  opts[:scope_opts] = (opts[:scope_opts] || {})
256
329
  opts[:scope_opts].merge!(window_opts: stmt.last.merge(cdb: cdb))
330
+ @window_options = (@window_options || {}).merge(stmt.last)
257
331
  extract_statement(stmt[1], opts)
258
332
  elsif stmt.length == 1 && stmt.first.is_a?(Array)
259
333
  extract_statement(stmt.first, opts)
@@ -90,7 +90,7 @@ module ConceptQL
90
90
  # Query#annotate sets it.
91
91
  attr_accessor :one_query_counts
92
92
 
93
- attr_reader :known_operators, :recall_stack, :recall_dependencies, :annotation, :opts, :query_columns
93
+ attr_reader :known_operators, :recall_stack, :recall_dependencies, :opts, :query_columns
94
94
 
95
95
  def initialize(opts = {})
96
96
  @known_operators = {}
@@ -98,11 +98,7 @@ module ConceptQL
98
98
  @recall_stack = []
99
99
  @label_cte_names = {}
100
100
  @operator_cte_names = {}.compare_by_identity
101
- @annotation = {}
102
101
  @opts = opts.dup
103
- @annotation[:errors] = @errors = {}
104
- @annotation[:warnings] = @warnings = {}
105
- @annotation[:counts] = @counts = {}
106
102
  @query_columns = DEFAULT_COLUMNS.keys
107
103
  @query_columns << :window_id if opts.dig(:window_opts, :window_table)
108
104
 
@@ -116,19 +112,6 @@ module ConceptQL
116
112
  "You must set the DOCKER_SCRATCH_DATABASE environment variable to the name of the scratch database if using the CONCEPTQL_FORCE_TEMP_TABLES environment variable"
117
113
  end
118
114
 
119
- def add_errors(key, errors)
120
- @errors[key] = errors
121
- end
122
-
123
- def add_warnings(key, errors)
124
- @warnings[key] = errors
125
- end
126
-
127
- def add_counts(key, domain, counts)
128
- c = @counts[key] ||= {}
129
- c[domain] = counts
130
- end
131
-
132
115
  # Operator#evaluate records the name of each CTE that holds an operator's
133
116
  # rows. An operator evaluated more than once has more than one name.
134
117
  def record_cte(op, name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConceptQL
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -15,9 +15,9 @@ module ConceptQL
15
15
  # whatever the event date. So a statement names the system, and the system
16
16
  # decides by the event date. ["ms_drg", "010"] selects the DRG 010 events
17
17
  # from 2007-10-01, and ["cms_drg", "010"] selects the DRG 010 events before
18
- # that day. The concept-ID lookup is the one that drg uses, so the
19
- # operators work with the OHDSI and the GDM lexicons alike. They use no
20
- # OHDSI validity date and no concept class to select events.
18
+ # that day. The concept-ID lookup is the one that drg uses. The
19
+ # operators use no OHDSI validity date and no concept class to select
20
+ # events.
21
21
  #
22
22
  # Each operator is a vocabulary operator of vocabulary DRG. So its code
23
23
  # format is the DRG row of vocabulary_formats (a vocabulary operator uses
@@ -3,15 +3,17 @@
3
3
  require "csv"
4
4
  require_relative "entry"
5
5
  require_relative "drg_systems"
6
+ require_relative "read_codes"
6
7
 
7
8
  module ConceptQL
8
9
  module Vocabularies
9
10
  class DynamicVocabularies
10
- # lexicon: the Lexicon whose vocabularies become operators, normally a
11
- # ConceptQL::Database's own (Database#operators passes it). Without one,
12
- # only the CSV vocabularies are used.
13
- def initialize(lexicon = nil)
14
- @lexicon = lexicon
11
+ # lexicons: the Lexicons whose vocabularies become operators, normally a
12
+ # ConceptQL::Database's own and its reference lexicon (Database#operators
13
+ # passes them). A nil one is left out. Without one, only the CSV
14
+ # vocabularies are used.
15
+ def initialize(*lexicons)
16
+ @lexicons = lexicons.compact
15
17
  end
16
18
 
17
19
  # Vocabulary operators for one data model, as name => operator class.
@@ -28,7 +30,8 @@ module ConceptQL
28
30
  end
29
31
 
30
32
  # Every vocabulary entry, as id => Entry, and the entries of ms_drg and
31
- # cms_drg, which copy the entry of drg (DrgSystems).
33
+ # cms_drg, which copy the entry of drg (DrgSystems). The entry of read
34
+ # gets its preferred name (ReadCodes).
32
35
  def all_vocabs
33
36
  @all_vocabs ||= begin
34
37
  vocabs = each_vocab.each_with_object({}) do |row, h|
@@ -36,13 +39,14 @@ module ConceptQL
36
39
  h[entry.id] ||= entry
37
40
  h[entry.id] = h[entry.id].merge(entry)
38
41
  end
39
- vocabs.merge(DrgSystems.entries(vocabs[DrgSystems::DRG_OPERATOR]))
42
+ vocabs.merge(DrgSystems.entries(vocabs[DrgSystems::DRG_OPERATOR]),
43
+ ReadCodes.entries(vocabs[ReadCodes::OPERATOR]))
40
44
  end
41
45
  end
42
46
 
43
47
  private
44
48
 
45
- attr_reader :lexicon
49
+ attr_reader :lexicons
46
50
 
47
51
  def each_vocab
48
52
  @each_vocab ||= get_all_vocabs
@@ -60,9 +64,10 @@ module ConceptQL
60
64
  end
61
65
 
62
66
  # A Database accepts any object as its lexicon (opts[:lexicon]); one that
63
- # keeps no vocabulary list contributes no vocabulary operators.
67
+ # keeps no vocabulary list contributes no vocabulary operators. A
68
+ # vocabulary in two lexicons gets one entry (#all_vocabs merges them).
64
69
  def lexicon_vocabularies
65
- lexicon.respond_to?(:vocabularies) ? lexicon.vocabularies : []
70
+ lexicons.flat_map { |lexicon| lexicon.respond_to?(:vocabularies) ? lexicon.vocabularies : [] }
66
71
  end
67
72
  end
68
73
  end
@@ -28,8 +28,8 @@ module ConceptQL
28
28
  predominant_domains
29
29
  ]
30
30
 
31
- # A blank domain is no domain (bead conceptql-7azx). The GDM vocabulary
32
- # table gives a vocabulary without a domain the domain '', and that
31
+ # A blank domain is no domain (bead conceptql-7azx). The removed GDM
32
+ # lexicon gave a vocabulary without a domain the domain '', and that
33
33
  # became the criterion_domain of its operators. Without the key, the
34
34
  # vocabulary selects condition_occurrence records (predominant_domains),
35
35
  # and a merge keeps the other entry's domain.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../behaviors/labish"
4
+
5
+ module ConceptQL
6
+ module Vocabularies
7
+ # The read operator: the vocabulary operator of the Read vocabulary (NHS UK
8
+ # Read Codes Version 2), built from its row of config/vocabularies.csv like
9
+ # every other vocabulary operator (bead conceptql-qdn9). Before, read was
10
+ # an operator class that wrapped one Vocabulary operator, so it got no
11
+ # vocabulary check. This module keeps the three things that the row
12
+ # cannot say: the preferred name READ, read's own description, and the
13
+ # lab columns (Labish). A Read code can carry a test result, so read
14
+ # selects the value columns although its domain is condition_occurrence.
15
+ #
16
+ # Read codes are case-sensitive: "13g4.00" (Born in Burkina Faso) and
17
+ # "13G4.00" (Social worker involved) are two codes. Every vocabulary
18
+ # operator compares codes case-exactly, so a code in another case selects
19
+ # nothing and is an unknown code.
20
+ module ReadCodes
21
+ OPERATOR = "read"
22
+ PREFERRED_NAME = "READ"
23
+ DESC = "Selects records based on the Read vocabulary."
24
+
25
+ # The entry of read: the entry of the Read vocabulary with the preferred
26
+ # name READ, as id => Entry. Without a Read entry there is none.
27
+ def self.entries(read_entry)
28
+ return {} unless read_entry
29
+
30
+ { OPERATOR => read_entry.merge(Entry.new(id: read_entry.omopv5_id, preferred_name: PREFERRED_NAME)) }
31
+ end
32
+
33
+ # The behavior of the read operator class
34
+ module OperatorBehavior
35
+ def self.included(base)
36
+ base.desc DESC
37
+ base.include ConceptQL::Behaviors::Labish
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -4,6 +4,7 @@ require_relative "../behaviors/drugish"
4
4
  require_relative "../behaviors/labish"
5
5
  require_relative "behaviors/gdmish"
6
6
  require_relative "drg_systems"
7
+ require_relative "read_codes"
7
8
  require_relative "../operators/vocabulary"
8
9
 
9
10
  module ConceptQL
@@ -66,6 +67,8 @@ module ConceptQL
66
67
 
67
68
  include DrgSystems::OperatorBehavior if DrgSystems.system(entry.id)
68
69
 
70
+ include ReadCodes::OperatorBehavior if entry.id == ReadCodes::OPERATOR
71
+
69
72
  deprecated DrgSystems::DRG_DEPRECATION if entry.id == DrgSystems::DRG_OPERATOR
70
73
 
71
74
  def vocab_entry
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: conceptql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Duryea
@@ -390,6 +390,7 @@ files:
390
390
  - docs/spec/examples/union-operator.json
391
391
  - docs/spec/examples/vocabulary-operator.json
392
392
  - lib/conceptql.rb
393
+ - lib/conceptql/annotation.rb
393
394
  - lib/conceptql/behaviors/code_lister.rb
394
395
  - lib/conceptql/behaviors/drugish.rb
395
396
  - lib/conceptql/behaviors/labish.rb
@@ -423,7 +424,6 @@ files:
423
424
  - lib/conceptql/diagram_renderer/svg.rb
424
425
  - lib/conceptql/diagram_renderer/tree_layout.rb
425
426
  - lib/conceptql/lexicon.rb
426
- - lib/conceptql/lexicon/lexicon_gdm.rb
427
427
  - lib/conceptql/lexicon/lexicon_no_db.rb
428
428
  - lib/conceptql/lexicon/lexicon_ohdsi.rb
429
429
  - lib/conceptql/lexicon/lexicon_strategy.rb
@@ -468,7 +468,6 @@ files:
468
468
  - lib/conceptql/operators/provenance.rb
469
469
  - lib/conceptql/operators/provider_filter.rb
470
470
  - lib/conceptql/operators/race.rb
471
- - lib/conceptql/operators/read.rb
472
471
  - lib/conceptql/operators/recall.rb
473
472
  - lib/conceptql/operators/snf.rb
474
473
  - lib/conceptql/operators/sum.rb
@@ -508,6 +507,7 @@ files:
508
507
  - lib/conceptql/vocabularies/dynamic_vocabularies.rb
509
508
  - lib/conceptql/vocabularies/entry.rb
510
509
  - lib/conceptql/vocabularies/formats.rb
510
+ - lib/conceptql/vocabularies/read_codes.rb
511
511
  - lib/conceptql/vocabularies/vocabulary_class_factory.rb
512
512
  - lib/conceptql/window.rb
513
513
  - lib/conceptql/window/base.rb