conceptql 0.4.1 → 0.6.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +498 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +2091 -0
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -3
  10. data/lib/conceptql/cli.rb +1 -1
  11. data/lib/conceptql/data_model/base.rb +3 -171
  12. data/lib/conceptql/data_model/gdm.rb +3 -1
  13. data/lib/conceptql/data_model.rb +20 -4
  14. data/lib/conceptql/database.rb +9 -2
  15. data/lib/conceptql/date_adjuster.rb +12 -5
  16. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  17. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  18. data/lib/conceptql/lexicon/lexicon_strategy.rb +58 -4
  19. data/lib/conceptql/lexicon.rb +218 -2
  20. data/lib/conceptql/nodifier.rb +7 -1
  21. data/lib/conceptql/one_query_counts.rb +120 -0
  22. data/lib/conceptql/operators/co_reported.rb +4 -31
  23. data/lib/conceptql/operators/count.rb +4 -0
  24. data/lib/conceptql/operators/death.rb +1 -1
  25. data/lib/conceptql/operators/dedup.rb +1 -1
  26. data/lib/conceptql/operators/episode.rb +10 -2
  27. data/lib/conceptql/operators/ethnicity.rb +0 -4
  28. data/lib/conceptql/operators/except.rb +5 -0
  29. data/lib/conceptql/operators/filter.rb +12 -2
  30. data/lib/conceptql/operators/hospice.rb +2 -3
  31. data/lib/conceptql/operators/hospitalization.rb +2 -3
  32. data/lib/conceptql/operators/information_periods.rb +5 -4
  33. data/lib/conceptql/operators/invalid.rb +7 -1
  34. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  35. data/lib/conceptql/operators/numeric.rb +51 -14
  36. data/lib/conceptql/operators/occurrence.rb +16 -4
  37. data/lib/conceptql/operators/one_in_two_out.rb +1 -2
  38. data/lib/conceptql/operators/operator.rb +66 -30
  39. data/lib/conceptql/operators/person.rb +1 -1
  40. data/lib/conceptql/operators/person_filter.rb +5 -0
  41. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  42. data/lib/conceptql/operators/provenance.rb +13 -4
  43. data/lib/conceptql/operators/provider_filter.rb +5 -1
  44. data/lib/conceptql/operators/race.rb +2 -10
  45. data/lib/conceptql/operators/read.rb +1 -100
  46. data/lib/conceptql/operators/recall.rb +1 -1
  47. data/lib/conceptql/operators/snf.rb +3 -4
  48. data/lib/conceptql/operators/sum.rb +46 -5
  49. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  50. data/lib/conceptql/operators/trim_date.rb +52 -6
  51. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  52. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  53. data/lib/conceptql/operators/union.rb +45 -1
  54. data/lib/conceptql/operators/vocabulary.rb +21 -4
  55. data/lib/conceptql/paths.rb +16 -0
  56. data/lib/conceptql/query.rb +78 -24
  57. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  58. data/lib/conceptql/rdbms/generic.rb +21 -1
  59. data/lib/conceptql/rdbms/spark.rb +12 -0
  60. data/lib/conceptql/scope.rb +187 -24
  61. data/lib/conceptql/version.rb +1 -1
  62. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  63. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  64. data/lib/conceptql/vocabularies/entry.rb +15 -23
  65. data/lib/conceptql/vocabularies/formats.rb +59 -0
  66. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
  67. data/lib/conceptql/window/table.rb +12 -3
  68. metadata +9 -14
  69. data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
  70. data/lib/conceptql/operators/concurrent_within.rb +0 -69
  71. data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
  72. data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
  73. data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
  74. data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
  75. data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
  76. data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
  77. data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
  78. data/schemas/omopv4_plus.yml +0 -729
  79. data/schemas/omopv4_plus_more_ignorables.tsv +0 -13
@@ -38,6 +38,15 @@ module ConceptQL
38
38
  @conceptql_spec_id = value
39
39
  end
40
40
 
41
+ # Marks an operator as deprecated. It still runs. Operator#validate adds
42
+ # the message to its annotation as a warning, and its metadata says
43
+ # deprecated: true. A subclass does not inherit the mark.
44
+ def deprecated(message = nil)
45
+ return @deprecated unless message
46
+
47
+ @deprecated = message
48
+ end
49
+
41
50
  def predominant_domains(*values)
42
51
  return @predominant_domains if values.empty?
43
52
 
@@ -150,11 +159,12 @@ module ConceptQL
150
159
  max_upstreams: @max_upstreams || 0,
151
160
  arguments: @arguments || [],
152
161
  options: process_options(@options),
153
- predominant_domains: @domains || @predominant_domains || [],
162
+ # Strings, as JSON gives them: domains (for example death) are symbols
163
+ predominant_domains: (@domains || @predominant_domains || []).map(&:to_s),
154
164
  desc: get_desc,
155
165
  categories: @categories || [],
156
166
  basic_type: @basic_type,
157
- deprecated: nil, # kept for metadata consumers; no operator is deprecated
167
+ deprecated: (true if deprecated), # nil for an operator that is not deprecated
158
168
  arity: arity,
159
169
  aliases: @aliases,
160
170
  spec_id: spec_id
@@ -176,8 +186,10 @@ module ConceptQL
176
186
  end
177
187
  end
178
188
 
189
+ # The desc that the operator set (no_desc sets ""), else the standard
190
+ # vocabulary text. A subclass does not inherit its parent's desc.
179
191
  def get_desc
180
- @get_desc ||= standard_description
192
+ @desc || standard_description
181
193
  end
182
194
 
183
195
  def process_options(options)
@@ -1,26 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConceptQL
4
+ # Provenance keywords select records by their file provenance
5
+ # (file_provenance_type) and their code provenance (code_provenance_type).
6
+ #
7
+ # A plain keyword, such as "inpatient" or "primary", names a concept in the
8
+ # file or the code provenance vocabulary. It keeps a record when either
9
+ # column is in the descendants of that concept.
10
+ #
11
+ # A keyword "<file keyword>:<code keyword>", such as
12
+ # "inpatient:primary_or_first", keeps a record when its file provenance is
13
+ # in the descendants of the file keyword AND its code provenance is in the
14
+ # descendants of the code keyword.
15
+ #
16
+ # Several keywords are ORed: a record is kept when any keyword keeps it.
17
+ #
18
+ # The keyword "*" keeps a record when either column is any provenance
19
+ # concept. Because keywords are ORed, "*" with other keywords is "*".
4
20
  module Provenanceable
21
+ PAIR_SEPARATOR = ":"
22
+ ANY = "*"
23
+
5
24
  def self.included(base)
6
25
  base.require_column :file_provenance_type
7
26
  base.require_column :code_provenance_type
8
27
  end
9
28
 
10
- def prov_of(db, ancestors)
11
- dm.descendants_of(db,
12
- dm.concept_ids(db, [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten,
13
- ancestors))
29
+ # No keyword at all is a validation error of Provenance. It still builds
30
+ # the condition of "*", as an invalid pair still builds a condition.
31
+ def build_where_from_codes(db, codes)
32
+ return plain_condition(db, []) if codes.empty? || codes.include?(ANY)
33
+
34
+ pairs, plain = codes.partition { |code| pair_keyword?(code) }
35
+ conditions = pairs.map { |pair| pair_condition(db, pair) }
36
+ conditions.unshift(plain_condition(db, plain)) if plain.any?
37
+ return conditions.first if conditions.one?
38
+
39
+ Sequel.|(*conditions)
14
40
  end
15
41
 
16
- def build_where_from_codes(db, codes)
42
+ # The keywords as written that name no provenance concept. A pair is
43
+ # recognized only when it has one separator, a file keyword on the left
44
+ # and a code keyword on the right. Case is compared exactly, as the lookup
45
+ # compares it: the provenance vocabularies have no vocabulary_formats row
46
+ # that declares case-insensitivity (seed conceptql-oji ruling "b"). So
47
+ # "Inpatient" names no concept, and the concept codes are in lower case.
48
+ # "*" is known. It is not a half of a pair.
49
+ def find_bad_keywords(db, codes)
50
+ pairs, plain = (codes - [ ANY ]).partition { |code| pair_keyword?(code) }
51
+ known = known_keywords(db, all_provenance_types_vocab)
52
+ bad = plain.reject { |code| known.include?(code.to_s) }
53
+ bad += pairs.reject { |pair| known_pair?(db, pair) }
54
+ codes & bad
55
+ end
56
+
57
+ private
58
+
59
+ def pair_keyword?(code)
60
+ code.to_s.include?(PAIR_SEPARATOR)
61
+ end
62
+
63
+ def known_pair?(db, pair)
64
+ file, code, *rest = pair.split(PAIR_SEPARATOR, -1)
65
+ rest.empty? &&
66
+ known_keywords(db, dm.file_provenance_types_vocab).include?(file) &&
67
+ known_keywords(db, dm.code_provenance_types_vocab).include?(code)
68
+ end
69
+
70
+ # The concept codes of the vocabularies.
71
+ def known_keywords(db, vocabularies)
72
+ dm.concept_codes(db, vocabularies)
73
+ end
74
+
75
+ def all_provenance_types_vocab
76
+ [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten
77
+ end
78
+
79
+ def prov_of(db, vocabularies, ancestors)
80
+ dm.descendants_of(db, dm.concept_ids(db, vocabularies, ancestors))
81
+ end
82
+
83
+ def plain_condition(db, codes)
17
84
  Sequel.|(
18
- { file_provenance_type: prov_of(db, codes) }, { code_provenance_type: prov_of(db, codes) }
85
+ { file_provenance_type: prov_of(db, all_provenance_types_vocab, codes) },
86
+ { code_provenance_type: prov_of(db, all_provenance_types_vocab, codes) }
19
87
  )
20
88
  end
21
89
 
22
- def find_bad_keywords(db, codes)
23
- codes - dm.concept_codes(db, [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten)
90
+ # An invalid pair (validation reports it) still builds a condition. A
91
+ # half with no concept matches nothing, so the pair keeps no record.
92
+ def pair_condition(db, pair)
93
+ file, code = pair.split(PAIR_SEPARATOR, 2)
94
+ Sequel.&(
95
+ { file_provenance_type: prov_of(db, dm.file_provenance_types_vocab, [ file ]) },
96
+ { code_provenance_type: prov_of(db, dm.code_provenance_types_vocab, [ code ]) }
97
+ )
24
98
  end
25
99
  end
26
100
  end
@@ -41,13 +41,17 @@ module ConceptQL
41
41
  # With the Database's share_utilization option, the list is read from a
42
42
  # table that every statement on the Database shares. The WITH entry only
43
43
  # carries the list to Scope#with_ctes, which takes it out of the SQL and
44
- # creates the table when the query first reads rows.
44
+ # creates the table when the query first reads rows. With
45
+ # CONCEPTQL_AVOID_CTES, with_ctes extracts nothing, so the scope keeps
46
+ # the table instead.
45
47
  def evaluate(db)
46
48
  cdb = nodifier.cdb
47
49
  return super unless cdb.opts[:share_utilization]
48
50
 
49
51
  list = select_it(query(db))
50
52
  name = cdb.shared_utilization_table(list, scope)
53
+ return scope.add_shared_table(db, name, list) if ConceptQL.avoid_ctes?
54
+
51
55
  db[name].with(name, list, Scope::SHARED_TABLE => true)
52
56
  end
53
57
 
@@ -170,8 +174,6 @@ module ConceptQL
170
174
  end
171
175
 
172
176
  def override_columns
173
- return unless gdm?
174
-
175
177
  {
176
178
  length_of_stay: (rdbms.days_between(Sequel[:admission_date],
177
179
  discharge_date_column) + 1).as(:length_of_stay),
data/lib/conceptql/cli.rb CHANGED
@@ -94,7 +94,7 @@ module ConceptQL
94
94
  given by the connection options (or sequelizer's configuration).
95
95
  DESC
96
96
  option :counts, type: :boolean, default: false, desc: "annotate with per-domain row/person counts (needs a DB)"
97
- option :data_model, desc: "data model: gdm, gdm_wide, omopv4_plus (default: $CONCEPTQL_DATA_MODEL or gdm)"
97
+ option :data_model, desc: "data model: gdm, gdm_wide (default: $CONCEPTQL_DATA_MODEL or gdm)"
98
98
  def render_json(statement_file)
99
99
  cdb = if options[:counts]
100
100
  ConceptQL::Database.new(db(options.except("counts", "data_model")),
@@ -10,6 +10,7 @@ module ConceptQL
10
10
  def_delegators :nodifier, :cdb
11
11
  def_delegators :cdb, :lexicon, :source_table_exists?
12
12
  def_delegators :lexicon,
13
+ :code_meanings,
13
14
  :concept_codes,
14
15
  :concept_ids,
15
16
  :concept_ids_by_name,
@@ -18,6 +19,7 @@ module ConceptQL
18
19
  :concepts_by_name,
19
20
  :concepts_ds,
20
21
  :concepts_to_codes,
22
+ :concepts_to_codes_in_class,
21
23
  :descendants_of,
22
24
  :known_codes,
23
25
  :related_concept_ids
@@ -47,79 +49,14 @@ module ConceptQL
47
49
  db[table]
48
50
  end
49
51
 
50
- # The data columns whose concept ids the display-name joins look up, by
51
- # table. A data model without those joins has none.
52
- def name_concept_columns
53
- {}
54
- end
55
-
56
- def query_modifier_for(column)
57
- {
58
- visit_source_concept_id: ConceptQL::QueryModifiers::Omopv4Plus::PoSQueryModifier,
59
- provider_id: ConceptQL::QueryModifiers::Omopv4Plus::ProviderQueryModifier,
60
- context_specialty_concept_id: ConceptQL::QueryModifiers::Omopv4Plus::ProviderQueryModifier,
61
- drug_name: ConceptQL::QueryModifiers::Omopv4Plus::DrugQueryModifier,
62
- admission_date: ConceptQL::QueryModifiers::Omopv4Plus::AdmissionDateQueryModifier,
63
- provenance_type: ConceptQL::QueryModifiers::Omopv4Plus::ProvenanceQueryModifier
64
- }[column]
65
- end
66
-
67
- def criterion_id
68
- :criterion_id
69
- end
70
-
71
- def table_id(table = nil)
72
- return :person_id if table.to_sym == :death
73
-
74
- :criterion_id if table.nil?
75
- end
76
-
77
- def person_table
78
- :person
79
- end
80
-
81
- def make_table_id(table)
82
- "#{table}_id".to_sym
83
- end
84
-
85
52
  def make_fk_id(table)
86
53
  make_table_id(table)
87
54
  end
88
55
 
89
- def person_id(table)
90
- case table.to_sym
91
- when :procedure_cost, :drug_cost
92
- nil
93
- else
94
- :person_id
95
- end
96
- end
97
-
98
56
  def schema
99
57
  SCHEMAS[data_model]
100
58
  end
101
59
 
102
- def data_model
103
- :omopv4_plus
104
- end
105
-
106
- def pk_by_domain(domain)
107
- case domain.to_sym
108
- when :death
109
- :person_id
110
- when :procedure_cost
111
- :procedure_occurrence_id
112
- when :drug_cost
113
- :drug_exposure_id
114
- else
115
- "#{domain}_id".to_sym
116
- end
117
- end
118
-
119
- def fk_by_domain(domain)
120
- "#{domain}_id".to_sym
121
- end
122
-
123
60
  def query_columns
124
61
  @query_columns ||= Hash[operator.scope.query_columns.zip(operator.scope.query_columns)]
125
62
  end
@@ -231,10 +168,6 @@ module ConceptQL
231
168
  }
232
169
  end
233
170
 
234
- def table_to_domain(table)
235
- table
236
- end
237
-
238
171
  def replace(replace_hash)
239
172
  return {} unless replace_hash
240
173
 
@@ -276,14 +209,6 @@ module ConceptQL
276
209
  end
277
210
  end
278
211
 
279
- def start_date_column(query, domain)
280
- start_date_columns.merge(person: person_date_of_birth(query))[domain]
281
- end
282
-
283
- def end_date_column(query, domain)
284
- end_date_columns.merge(person: person_date_of_birth(query))[domain]
285
- end
286
-
287
212
  def source_value_columns
288
213
  @source_value_columns ||= assign_column_to_table do |table, columns|
289
214
  reggy = /#{table.to_s.split('_').first}_source_value$/
@@ -292,81 +217,10 @@ module ConceptQL
292
217
  end
293
218
  end
294
219
 
295
- def source_vocabulary_ids
296
- @source_vocabulary_ids = assign_column_to_table do |table, columns|
297
- next if %w[person death].any? { |tn| table.to_s =~ /#{tn}/ }
298
-
299
- reggy = /#{table.to_s.split('_').first}_source_vocabulary_id/
300
- column = columns.select { |k| k =~ reggy }.first
301
- column || columns.select { |k| k =~ /_source_vocabulary_id/ }.first
302
- end
303
- end
304
-
305
- def id_columns
306
- @id_columns ||= assign_column_to_table do |table, columns|
307
- reggy = /#{table}_id/
308
- columns.select { |k| k =~ reggy }.first
309
- end
310
- end
311
-
312
- def id_column(table)
313
- id_columns[table]
314
- end
315
-
316
- def type_concept_id_columns
317
- @type_concept_id_columns ||= assign_column_to_table do |_table, columns|
318
- columns.select { |c| c =~ /_type_concept_id$/ }.first
319
- end
320
- end
321
-
322
- def type_concept_id_column(domain)
323
- type_concept_id_columns[table_by_domain(domain)]
324
- end
325
-
326
- def table_by_domain(domain)
327
- domain
328
- end
329
-
330
- def period_table
331
- :observation_period
332
- end
333
-
334
220
  def source_value_column(table)
335
221
  source_value_columns[table]
336
222
  end
337
223
 
338
- def code_provenance_type_column(_query, domain)
339
- {
340
- condition_occurrence: :condition_type_concept_id,
341
- death: :death_type_concept_id,
342
- drug_exposure: :drug_type_concept_id,
343
- observation: :observation_type_concept_id,
344
- procedure_occurrence: :procedure_type_concept_id
345
- }[domain]
346
- end
347
-
348
- def file_provenance_type_column(_query, domain)
349
- {
350
- condition_occurrence: :condition_type_concept_id,
351
- death: :death_type_concept_id,
352
- drug_exposure: :drug_type_concept_id,
353
- observation: :observation_type_concept_id,
354
- procedure_occurrence: :procedure_type_concept_id
355
- }[domain]
356
- end
357
-
358
- def provider_id_column(_query, domain)
359
- {
360
- condition_occurrence: :associated_provider_id,
361
- death: :death_type_concept_id,
362
- drug_exposure: :prescribing_provider_id,
363
- observation: :associated_provider_id,
364
- person: :provider_id,
365
- procedure_occurrence: :associated_provider_id,
366
- provider: :provider_id
367
- }[domain]
368
- end
369
-
370
224
  def place_of_service_concept_id_column(_query, domain)
371
225
  return nil if domain.nil?
372
226
  return Sequel.cast(:visit_source_concept_id, :Bigint) if table_cols(domain).include?(pos_table_fk)
@@ -374,15 +228,6 @@ module ConceptQL
374
228
  nil
375
229
  end
376
230
 
377
- def pos_table_fk
378
- :visit_occurrence_id
379
- end
380
-
381
- def person_date_of_birth(_query)
382
- rdbms.cast_date(ConceptQL::Utils.assemble_date(:year_of_birth, :month_of_birth, :day_of_birth,
383
- database_type: nodifier.database_type))
384
- end
385
-
386
231
  def date_columns(query, table = nil)
387
232
  sd = start_date_column(query, table)
388
233
  sd = rdbms.cast_date(Sequel.expr(sd)).as(:start_date) unless sd == :start_date
@@ -414,10 +259,7 @@ module ConceptQL
414
259
  end
415
260
 
416
261
  def table_columns(*tables)
417
- tables.map { |t| table_cols(t) }.flatten.tap do |o|
418
- p o
419
- p tables
420
- end
262
+ tables.flat_map { |t| table_cols(t) }
421
263
  end
422
264
 
423
265
  def table_source_value(table)
@@ -428,10 +270,6 @@ module ConceptQL
428
270
  source_vocabulary_ids[table_to_sym(table)]
429
271
  end
430
272
 
431
- def table_is_missing?(db)
432
- !(source_table_exists?(db, :observation_period) && source_table_exists?(db, :condition_occurrence))
433
- end
434
-
435
273
  def concept_id(table)
436
274
  table_cols(table).find do |col|
437
275
  col = col.to_s
@@ -451,12 +289,6 @@ module ConceptQL
451
289
  end
452
290
  end
453
291
  end
454
-
455
- def information_period_where_clause(arguments)
456
- return if arguments.empty?
457
-
458
- { plan_source_value: arguments }
459
- end
460
292
  end
461
293
  end
462
294
  end
@@ -115,8 +115,10 @@ module ConceptQL
115
115
  :context_id
116
116
  end
117
117
 
118
+ # The schema of a data model does not change, so the answer is kept, as
119
+ # for start_date_columns and the other column lookups.
118
120
  def source_vocabulary_ids
119
- @source_vocabulary_ids = assign_column_to_table do |table, columns|
121
+ @source_vocabulary_ids ||= assign_column_to_table do |table, columns|
120
122
  next if %w[patients death].any? { |tn| table.to_s =~ /#{tn}/ }
121
123
 
122
124
  reggy = /#{table.to_s.split('_').first}_vocabulary_id/
@@ -2,11 +2,29 @@
2
2
 
3
3
  require_relative "data_model/gdm"
4
4
  require_relative "data_model/gdm_wide"
5
- require_relative "data_model/omopv4_plus"
6
5
  require_relative "rdbms"
7
6
 
8
7
  module ConceptQL
9
8
  module DataModel
9
+ SUPPORTED = %i[gdm gdm_wide].freeze
10
+
11
+ # Raised when a caller asks for a data model that ConceptQL does not have.
12
+ class UnsupportedError < ArgumentError
13
+ REMOVED = { omopv4_plus: "0.5.0" }.freeze
14
+
15
+ def initialize(data_model)
16
+ message = "ConceptQL has no data model #{data_model.inspect}. Use one of: #{SUPPORTED.join(', ')}."
17
+ if (version = REMOVED[data_model])
18
+ message = "The #{data_model} data model was removed in ConceptQL #{version}. Use one of: #{SUPPORTED.join(', ')}."
19
+ end
20
+ super(message)
21
+ end
22
+ end
23
+
24
+ def self.assert_supported!(data_model)
25
+ raise UnsupportedError, data_model unless SUPPORTED.include?(data_model)
26
+ end
27
+
10
28
  def self.get(data_model, opts = {})
11
29
  operator = opts[:operator]
12
30
  nodifier = opts[:nodifier]
@@ -16,10 +34,8 @@ module ConceptQL
16
34
  Gdm.new(operator, nodifier)
17
35
  when :gdm_wide
18
36
  GdmWide.new(operator, nodifier)
19
- when :omopv4_plus
20
- Omopv4Plus.new(operator, nodifier)
21
37
  else
22
- raise "No DataModel defined for #{data_model.inspect}"
38
+ raise UnsupportedError, data_model
23
39
  end
24
40
  end
25
41
 
@@ -28,6 +28,7 @@ module ConceptQL
28
28
  @opts = ConceptQL::Utils.rekey(opts, rekey_values: true)
29
29
 
30
30
  @opts[:data_model] ||= (ENV["CONCEPTQL_DATA_MODEL"] || ConceptQL::DEFAULT_DATA_MODEL).to_sym
31
+ ConceptQL::DataModel.assert_supported!(@opts[:data_model])
31
32
 
32
33
  self.class.db_extensions(db, @opts[:data_model], @opts[:use_cold_col])
33
34
  db_type = db ? db.database_type.to_sym : :postgres
@@ -94,8 +95,10 @@ module ConceptQL
94
95
  end
95
96
 
96
97
  # The LEXICON_URL database, or nil when it is unset. Lexicon tries the
97
- # data db first and, when neither has the vocabulary tables, falls back
98
- # to LexiconNoDB, so no database (and no sqlite3 gem) is needed here.
98
+ # data db first. When neither has the vocabulary tables, a real data db
99
+ # raises Lexicon::NoVocabularyError, and a db-less Database (db nil or a
100
+ # Sequel mock) falls back to LexiconNoDB, so no database (and no sqlite3
101
+ # gem) is needed here.
99
102
  def make_lexicon_db
100
103
  return unless ENV["LEXICON_URL"]
101
104
 
@@ -125,6 +128,9 @@ module ConceptQL
125
128
  @source_tables.fetch(key) { db.table_exists?(table) && (@source_tables[key] = true) }
126
129
  end
127
130
 
131
+ # The Lexicon passed as lexicon:, or one built from the data db and the
132
+ # LEXICON_URL database. Database.new calls it, so a real data db without
133
+ # vocabulary tables raises Lexicon::NoVocabularyError there.
128
134
  def lexicon
129
135
  @lexicon ||= Lexicon.new(self.class.lexicon_db, db, cache: @opts[:cache_lookups])
130
136
  end
@@ -139,6 +145,7 @@ module ConceptQL
139
145
  # differs from this Database's.
140
146
  def operators(data_model = opts[:data_model])
141
147
  data_model = data_model.to_sym
148
+ ConceptQL::DataModel.assert_supported!(data_model)
142
149
  @operators ||= {}
143
150
  @operators[data_model] ||= begin
144
151
  vocabulary_operators = Vocabularies::DynamicVocabularies.new(lexicon).operators(data_model)
@@ -26,8 +26,7 @@ module ConceptQL
26
26
 
27
27
  origin_column = column
28
28
 
29
- chrs = str.chars
30
- while (chr = chrs.shift) =~ /[res]/i
29
+ prefix.each_char do |chr|
31
30
  if chr.downcase == "r"
32
31
  reverse = true
33
32
  else
@@ -53,13 +52,20 @@ module ConceptQL
53
52
  op.rdbms.cast_date(adjusted_date)
54
53
  end
55
54
 
56
- # Returns an array of strings that represent date modifiers
55
+ # Returns an array of [units, quantity] pairs that represent date modifiers.
56
+ # An "r" in the prefix reverses them, as it does in #adjust.
57
57
  def adjustments(reverse = false)
58
- @adjustments ||= parse(str, reverse)
58
+ reverse ||= prefix.match?(/r/i)
59
+ (@adjustments ||= {})[reverse] ||= parse(str.delete_prefix(prefix), reverse)
59
60
  end
60
61
 
61
62
  private
62
63
 
64
+ # The leading e/r/s characters, which pick the origin column and the direction
65
+ def prefix
66
+ str[/\A[res]*/i]
67
+ end
68
+
63
69
  def lookup
64
70
  {
65
71
  "y" => :years,
@@ -71,7 +77,8 @@ module ConceptQL
71
77
 
72
78
  def parse(str, reverse)
73
79
  return [] if str.nil? || str.empty?
74
- return [ [ lookup["d"], str.to_i ] ] if str.match(/^[-+]?\d+$/)
80
+ # A bare number means days
81
+ return [ [ lookup["d"], reverse ? -str.to_i : str.to_i ] ] if str.match?(/\A[-+]?\d+\z/)
75
82
 
76
83
  str.downcase.scan(/([-+]?\d*[dwmy])/).map do |adjustment|
77
84
  adjustment = adjustment.first
@@ -4,9 +4,12 @@ require_relative "lexicon_strategy"
4
4
 
5
5
  module ConceptQL
6
6
  class LexiconGDM < LexiconStrategy
7
+ # The GDM vocabulary tables. A db that has all four can be the lexicon.
8
+ VOCABULARY_TABLES = %i[ancestors concepts mappings vocabularies].freeze
9
+
7
10
  class << self
8
11
  def db_has_all_vocabulary_tables?(db)
9
- %i[ancestors concepts mappings vocabularies].all? { |t| db.table_exists?(t) }
12
+ VOCABULARY_TABLES.all? { |t| db.table_exists?(t) }
10
13
  end
11
14
  end
12
15
 
@@ -4,9 +4,12 @@ require_relative "lexicon_strategy"
4
4
 
5
5
  module ConceptQL
6
6
  class LexiconOhdsi < LexiconStrategy
7
+ # The OHDSI vocabulary tables. A db that has all four can be the lexicon.
8
+ VOCABULARY_TABLES = %i[concept concept_ancestor concept_relationship vocabulary].freeze
9
+
7
10
  class << self
8
11
  def db_has_all_vocabulary_tables?(db)
9
- %i[concept_ancestor concept concept_relationship vocabulary].all? { |t| db.table_exists?(t) }
12
+ VOCABULARY_TABLES.all? { |t| db.table_exists?(t) }
10
13
  end
11
14
  end
12
15
 
@@ -56,6 +59,21 @@ module ConceptQL
56
59
  !db.table_exists?(:concept)
57
60
  end
58
61
 
62
+ # A caller can answer "is the concept table missing?" with a block. A
63
+ # concept table without concept_class_id answers nil, as a lexicon
64
+ # without classes does.
65
+ def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes)
66
+ return nil if codes.blank? || db_is_mock?(data_db)
67
+ return nil if block_given? ? yield : table_is_missing?(data_db)
68
+
69
+ db[:concept]
70
+ .where(vocabulary_id: vocabulary_id, concept_class_id: concept_class_id)
71
+ .where(code_condition(vocabulary_id, codes))
72
+ .select_map(%i[concept_code concept_name])
73
+ rescue Sequel::DatabaseError
74
+ nil
75
+ end
76
+
59
77
  def strategy
60
78
  :ohdsi
61
79
  end