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
@@ -29,20 +29,20 @@ module ConceptQL
29
29
  Sequel[:p][:specialty_concept_id].as(:practitioner_specialty_concept_id)
30
30
  )
31
31
  else
32
+ # The data model names the columns of the source table. ds.columns
33
+ # asked the database for them while the SQL was built (bead
34
+ # conceptql-6k8).
35
+ cols = dm.table_cols(source_table)
32
36
  ds = query.select_all
33
- ds = if ds.columns.include?(:provider_id)
37
+ ds = if cols.include?(:provider_id)
34
38
  ds
35
- elsif dm.table_cols(source_table).include?(:practitioner_id)
39
+ elsif cols.include?(:practitioner_id)
36
40
  ds.select_append(Sequel[:practitioner_id].as(:provider_id))
37
41
  else
38
42
  ds.select_append(Sequel.cast(nil, :Bigint).as(:provider_id))
39
43
  end
40
44
  %i[role_type_concept_id context_specialty_concept_id practitioner_specialty_concept_id].each do |col|
41
- ds = if ds.columns.include?(col)
42
- ds
43
- else
44
- ds.select_append(Sequel.cast(nil, :Bigint).as(col))
45
- end
45
+ ds = ds.select_append(Sequel.cast(nil, :Bigint).as(col)) unless cols.include?(col)
46
46
  end
47
47
  ds
48
48
  end.from_self
@@ -53,9 +53,13 @@ module ConceptQL
53
53
  .inject(:+)
54
54
  end
55
55
 
56
+ # A NULL part is an empty string. The || operator gives NULL when one
57
+ # part is NULL, and a NULL uuid matches nothing (bead conceptql-18n).
58
+ # COALESCE keeps the place of each part, so "1//t/2008-01-01/" cannot
59
+ # be read as other parts. Postgres, DuckDB and Spark all support it.
56
60
  def uuid_items
57
61
  %w[person_id criterion_id criterion_table start_date end_date].map do |column|
58
- Sequel.cast_string(column.to_sym)
62
+ Sequel.function(:coalesce, Sequel.cast_string(column.to_sym), "")
59
63
  end
60
64
  end
61
65
 
@@ -79,6 +83,22 @@ module ConceptQL
79
83
  false
80
84
  end
81
85
 
86
+ # True if an IN subquery may sit in a SELECT-list expression, such as
87
+ # the CASE flags of One In Two Out. Scope#with_ctes reads a long IN list
88
+ # from its list CTE there only when this is true.
89
+ def supports_in_subquery_in_select?
90
+ true
91
+ end
92
+
93
+ # True if a NOT IN subquery may sit in a nested condition, such as the
94
+ # "IS NULL OR NOT IN" of the gender operator. Scope#with_ctes reads a
95
+ # long NOT IN list from its list CTE only when this is true. It does not
96
+ # check whether a NOT IN condition is nested, so false keeps every long
97
+ # NOT IN list a NOT IN list.
98
+ def supports_nested_not_in_subquery?
99
+ true
100
+ end
101
+
82
102
  def create_options(_scope, _ds)
83
103
  opts = {}
84
104
  opts[:analyze] = opts[:explain] = explain_temp_tables?
@@ -13,6 +13,18 @@ module ConceptQL
13
13
  def null_safe_equal(lhs, rhs)
14
14
  Sequel.lit("(? <=> ?)", lhs, rhs)
15
15
  end
16
+
17
+ # Spark accepts an IN subquery only in a filter or a join: "IN/EXISTS
18
+ # predicate sub-queries can only be used in Filter/Join" (Spark 3.3).
19
+ def supports_in_subquery_in_select?
20
+ false
21
+ end
22
+
23
+ # Spark rejects a NOT IN subquery in a nested condition: "Null-aware
24
+ # predicate sub-queries cannot be used in nested conditions" (Spark 3.3).
25
+ def supports_nested_not_in_subquery?
26
+ false
27
+ end
16
28
  end
17
29
  end
18
30
  end
@@ -86,6 +86,10 @@ module ConceptQL
86
86
 
87
87
  attr_accessor :person_ids
88
88
 
89
+ # The OneQueryCounts that Operator#annotate reads its counts from, or nil.
90
+ # Query#annotate sets it.
91
+ attr_accessor :one_query_counts
92
+
89
93
  attr_reader :known_operators, :recall_stack, :recall_dependencies, :annotation, :opts, :query_columns
90
94
 
91
95
  def initialize(opts = {})
@@ -93,6 +97,7 @@ module ConceptQL
93
97
  @recall_dependencies = {}
94
98
  @recall_stack = []
95
99
  @label_cte_names = {}
100
+ @operator_cte_names = {}.compare_by_identity
96
101
  @annotation = {}
97
102
  @opts = opts.dup
98
103
  @annotation[:errors] = @errors = {}
@@ -124,6 +129,16 @@ module ConceptQL
124
129
  c[domain] = counts
125
130
  end
126
131
 
132
+ # Operator#evaluate records the name of each CTE that holds an operator's
133
+ # rows. An operator evaluated more than once has more than one name.
134
+ def record_cte(op, name)
135
+ (@operator_cte_names[op] ||= []) << name
136
+ end
137
+
138
+ def cte_names(op)
139
+ @operator_cte_names.fetch(op, [])
140
+ end
141
+
127
142
  def add_required_columns(op)
128
143
  @query_columns |= op.required_columns if op.required_columns
129
144
  end
@@ -242,24 +257,70 @@ module ConceptQL
242
257
  true
243
258
  end
244
259
 
260
+ # An IN list of at least this many integers (concept IDs, mostly) goes to
261
+ # the database once, as a CTE that each copy of the list reads
262
+ # (concept_list_condition). Postgres spends planning time on each copy of
263
+ # a long IN list: one 111-operator algorithm spent 485 ms planning 52
264
+ # copies of 2,442-item provenance lists, and 156 ms with one CTE for each
265
+ # list. A shorter list stays an IN list, because a list CTE can change the
266
+ # Postgres plan for the worse: with a 231-item list as a CTE, the count
267
+ # statement of a test statement ran in 36 ms instead of 14 ms.
268
+ CONCEPT_LIST_MIN_ITEMS = 1000
269
+
270
+ # Takes the CTEs out of the datasets in an expression, and replaces its
271
+ # long IN lists with concept lists. With datasets: false, it leaves the
272
+ # datasets as they are (for a SELECT list). With concept_lists: false, it
273
+ # leaves the IN lists as they are. With not_in_lists: false, it leaves the
274
+ # NOT IN lists as they are.
245
275
  class CteExtractor < Sequel::ASTTransformer
246
- def initialize(scope, ctes)
276
+ def initialize(scope, ctes, db, datasets: true, concept_lists: true, not_in_lists: true)
247
277
  super()
248
278
  @scope = scope
249
279
  @ctes = ctes
280
+ @db = db
281
+ @datasets = datasets
282
+ @concept_lists = concept_lists
283
+ @not_in_lists = not_in_lists
250
284
  end
251
285
 
252
286
  private
253
287
 
254
288
  def v(o)
255
289
  if o.is_a?(Sequel::Dataset)
256
- @scope.recursive_extract_ctes(o, @ctes)
290
+ @datasets ? @scope.recursive_extract_ctes(o, @ctes) : o
291
+ elsif @concept_lists && @scope.concept_list?(o) && (@not_in_lists || o.op == :IN)
292
+ @scope.concept_list_condition(o, @db, @ctes, v(o.args.first))
257
293
  else
258
294
  super
259
295
  end
260
296
  end
261
297
  end
262
298
 
299
+ # True for an IN or NOT IN condition on a list of at least
300
+ # CONCEPT_LIST_MIN_ITEMS integers.
301
+ def concept_list?(expr)
302
+ expr.is_a?(Sequel::SQL::BooleanExpression) && %i[IN NOT\ IN].include?(expr.op) &&
303
+ (list = expr.args[1]).is_a?(Array) && list.length >= CONCEPT_LIST_MIN_ITEMS && list.all?(Integer)
304
+ end
305
+
306
+ # expr (a concept_list? condition) with its list replaced by a subquery
307
+ # of the CTE that holds the list. Each distinct list has one CTE in the
308
+ # scope, which joins ctes. The rows are the same: the list has no NULL.
309
+ def concept_list_condition(expr, db, ctes, column)
310
+ ids = expr.args[1]
311
+ name = (@concept_lists ||= {})[ids] ||= cte_name(:concept_list)
312
+ ctes << [ name, concept_list_dataset(db, ids), @concept_list_options || {} ]
313
+ Sequel::SQL::BooleanExpression.new(expr.op, column, db.from(name).select(:concept_id))
314
+ end
315
+
316
+ # One concept_id column with a row for each ID, in SQL that Postgres,
317
+ # DuckDB and Spark all accept.
318
+ def concept_list_dataset(db, ids)
319
+ rows = ids.map { |id| "(#{db.literal(id)})" }.join(", ")
320
+ alias_sql = "#{db.literal(Sequel.identifier(:t))}(#{db.literal(Sequel.identifier(:concept_id))})"
321
+ db.dataset.with_sql("SELECT * FROM (VALUES #{rows}) AS #{alias_sql}")
322
+ end
323
+
263
324
  def recursive_extract_cte_expr(t, ctes)
264
325
  case t
265
326
  when Sequel::Dataset
@@ -314,8 +375,21 @@ module ConceptQL
314
375
  # p [:rec_compounds, ctes.map(&:first), compounds]
315
376
  end
316
377
 
378
+ # Spark rejects a NOT IN subquery in a nested condition, so there a
379
+ # NOT IN list stays a NOT IN list. @not_in_concept_lists and
380
+ # @select_concept_lists are nil when with_ctes did not run (a direct
381
+ # call), and nil rewrites.
382
+ not_in_lists = @not_in_concept_lists != false
317
383
  if (where = query.opts[:where])
318
- query = query.clone(where: CteExtractor.new(self, ctes).transform(where))
384
+ query = query.clone(where: CteExtractor.new(self, ctes, query.db, not_in_lists: not_in_lists).transform(where))
385
+ end
386
+
387
+ # One In Two Out flags its events with CASE expressions on the same
388
+ # provenance lists as its WHERE. Spark rejects an IN subquery in a
389
+ # SELECT list, so there the lists stay IN lists.
390
+ if (select = query.opts[:select])
391
+ extractor = CteExtractor.new(self, ctes, query.db, datasets: false, concept_lists: @select_concept_lists != false, not_in_lists: not_in_lists)
392
+ query = query.clone(select: extractor.transform(select))
319
393
  end
320
394
 
321
395
  query
@@ -335,39 +409,122 @@ module ConceptQL
335
409
  # reads before the dataset itself, so an order that is already correct
336
410
  # does not change. (sort_ctes sorts by layers, and would move CTEs.)
337
411
  def sort_extracted_ctes(ctes)
338
- unsorted = ctes.uniq(&:first)
339
- literals = unsorted.to_h { |name, ds| [ name, ds.db.literal(name) ] }
340
- deps = unsorted.to_h do |name, ds|
341
- sql = ds.sql
342
- [ name, literals.filter_map { |other, literal| other if other != name && sql.include?(literal) } ]
412
+ sorted_ctes_with_sql(ctes).map(&:entry)
413
+ end
414
+
415
+ # One extracted CTE in sorted_ctes_with_sql: its [name, dataset, WITH
416
+ # options] entry, its name as the adapter quotes it, its SQL, and the
417
+ # quoted names of the CTEs that its SQL reads.
418
+ SortedCte = Struct.new(:entry, :literal, :sql, :reads)
419
+
420
+ # The work of sort_extracted_ctes. It keeps each CTE's SQL, so that
421
+ # merge_identical_ctes does not build it again. The names are compared as
422
+ # quoted strings: a comparison of two Sequel identifiers is much slower.
423
+ def sorted_ctes_with_sql(ctes)
424
+ unsorted = ctes.uniq(&:first).map do |entry|
425
+ name, ds = entry
426
+ SortedCte.new(entry, ds.db.literal(name), ds.sql)
427
+ end
428
+ literals = unsorted.map(&:literal)
429
+ unsorted.each do |cte|
430
+ cte.reads = literals.select { |literal| !literal.equal?(cte.literal) && cte.sql.include?(literal) }
343
431
  end
344
432
 
345
433
  sorted = []
434
+ placed = Set.new
346
435
  until unsorted.empty?
347
- placed = sorted.map(&:first)
348
- index = unsorted.index { |name, _| (deps[name] - placed).empty? }
349
- raise(CteCycleError, unsorted.map { |name, _| literals[name] }) unless index
436
+ index = unsorted.index { |cte| cte.reads.all? { |literal| placed.include?(literal) } }
437
+ raise(CteCycleError, unsorted.map(&:literal)) unless index
350
438
 
351
- sorted << unsorted.delete_at(index)
439
+ cte = unsorted.delete_at(index)
440
+ placed << cte.literal
441
+ sorted << cte
352
442
  end
353
443
  sorted
354
444
  end
355
445
 
356
- def with_ctes(op, db, options = {})
446
+ # The opts key of the query that with_ctes returns. Its value maps the
447
+ # name of each CTE that merge_identical_ctes removed to the name of the
448
+ # CTE that the query reads in its place.
449
+ MERGED_CTES = :conceptql_merged_ctes
450
+
451
+ # Sorts the extracted CTEs (sort_extracted_ctes) and keeps one CTE of each
452
+ # group of CTEs with the same SQL and WITH options. A statement evaluates
453
+ # some operators more than once (a Recall, or an operator that reads its
454
+ # upstream twice), and each evaluation gives its CTE a new name. Each
455
+ # reader of a removed CTE reads the kept one instead. The CTEs come in
456
+ # dependency order, so a CTE's SQL is compared after the CTEs it reads are
457
+ # renamed. Thus two CTEs that read two copies of one CTE merge too.
458
+ #
459
+ # The CTEs that root_sql reads are never removed, because the root query
460
+ # is a dataset, not SQL text, and its names are not renamed.
461
+ #
462
+ # Each kept CTE's dataset becomes its SQL text (Dataset#with_sql), so the
463
+ # final query does not build that SQL again.
464
+ #
465
+ # Returns the kept [name, dataset, WITH options] entries in dependency
466
+ # order, and a Hash of each removed name to the name that replaces it.
467
+ def merge_identical_ctes(ctes, root_sql)
468
+ renames = {}
469
+ merged = {}
470
+ kept = {}
471
+ pattern = nil
472
+ pattern_size = 0
473
+ entries = sorted_ctes_with_sql(ctes).filter_map do |cte|
474
+ name, ds, with_opts = cte.entry
475
+ sql = cte.sql
476
+ if cte.reads.any? { |literal| renames.key?(literal) }
477
+ unless pattern_size == renames.length
478
+ pattern = Regexp.union(renames.keys)
479
+ pattern_size = renames.length
480
+ end
481
+ sql = sql.gsub(pattern, renames)
482
+ end
483
+
484
+ key = [ sql, with_opts ]
485
+ if (same = kept[key]) && !root_sql.include?(cte.literal)
486
+ renames[cte.literal] = same.literal
487
+ merged[name] = same.entry.first
488
+ next
489
+ end
490
+
491
+ kept[key] ||= cte
492
+ [ name, ds.db.dataset.with_sql(sql), with_opts ]
493
+ end
494
+ [ entries, merged ]
495
+ end
496
+
497
+ # The CTEs of each dataset in extra (an evaluated operator) join the WITH
498
+ # list too, so the query can read operators that op does not evaluate.
499
+ def with_ctes(op, db, options = {}, extra: [])
357
500
  raise "recall operator use without matching label" unless valid?
358
501
 
359
502
  query = op.evaluate(db)
503
+ extra.each { |ds| query = query.clone(with: query.opts[:with] + ds.opts[:with]) }
360
504
  rdbms = op.rdbms
505
+ # A concept list is read by many CTEs, so it runs once.
506
+ @concept_list_options = rdbms.supports_materialized? ? { materialized: true } : {}
507
+ @select_concept_lists = rdbms.supports_in_subquery_in_select?
508
+ @not_in_concept_lists = rdbms.supports_nested_not_in_subquery?
361
509
 
362
510
  query = query.from_self
363
511
  temp_tables = []
364
- ctes.each do |label, operator|
365
- temp_tables << [ label_cte_name(label), recursive_extract_ctes(operator.evaluate(db), temp_tables) ]
512
+ # With CONCEPTQL_AVOID_CTES, each operator is a subquery
513
+ # (Operator#evaluate) and Scope#from inlines each labeled operator, so
514
+ # nothing is extracted and a long IN list stays an IN list. Only the
515
+ # shared tables (add_shared_table) remain.
516
+ if ConceptQL.avoid_ctes?
517
+ temp_tables.concat(@shared_tables || [])
518
+ else
519
+ ctes.each do |label, operator|
520
+ temp_tables << [ label_cte_name(label), recursive_extract_ctes(operator.evaluate(db), temp_tables) ]
521
+ end
522
+ query = recursive_extract_ctes(query, temp_tables)
366
523
  end
367
- query = recursive_extract_ctes(query, temp_tables)
368
524
  shared_tables, temp_tables = temp_tables.partition { |_, _, with_opts| with_opts&.fetch(SHARED_TABLE, false) }
369
525
  shared_tables = shared_tables.uniq(&:first)
370
- temp_tables = sort_extracted_ctes(temp_tables)
526
+ temp_tables, merged = merge_identical_ctes(temp_tables, query.sql)
527
+ query = query.clone(MERGED_CTES => merged)
371
528
 
372
529
  if force_temp_tables?(options)
373
530
  scope = self
@@ -425,13 +582,11 @@ module ConceptQL
425
582
  end
426
583
  end
427
584
  else
428
- unless ConceptQL.avoid_ctes?
429
- temp_tables.each do |table_name, ds, with_opts|
430
- query = if with_opts.nil? || with_opts.empty?
431
- query.with(table_name, ds)
432
- else
433
- query.with(table_name, ds, with_opts)
434
- end
585
+ temp_tables.each do |table_name, ds, with_opts|
586
+ query = if with_opts.nil? || with_opts.empty?
587
+ query.with(table_name, ds)
588
+ else
589
+ query.with(table_name, ds, with_opts)
435
590
  end
436
591
  end
437
592
 
@@ -449,6 +604,14 @@ module ConceptQL
449
604
  shared_tables.empty? ? query : create_shared_tables_on_read(query, shared_tables, op)
450
605
  end
451
606
 
607
+ # With CONCEPTQL_AVOID_CTES, Behaviors::Utilizable#evaluate records each
608
+ # shared table here, because with_ctes extracts no WITH entry to find it
609
+ # in. Returns a dataset that reads the table.
610
+ def add_shared_table(db, name, list)
611
+ (@shared_tables ||= []) << [ name, list, { SHARED_TABLE => true } ]
612
+ db[name]
613
+ end
614
+
452
615
  # The sql_statements entries that create the shared tables that do not
453
616
  # exist yet. They come first, because the other entries read them.
454
617
  def shared_table_statements(shared_tables, op)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConceptQL
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module ConceptQL
6
+ module Vocabularies
7
+ # The two DRG systems and their operators, ms_drg and cms_drg (bead
8
+ # conceptql-alw, seed conceptql-7p3).
9
+ #
10
+ # CMS replaced the CMS-DRG system with MS-DRG on 2007-10-01 and reused
11
+ # most DRG codes for new meanings: DRG 010 meant "Nervous System
12
+ # Neoplasms" before that day and "Pancreas transplant" after it. One
13
+ # vocabulary, DRG, holds both systems, and an event row does not record
14
+ # which meaning applied: the ETL stores one concept ID for each source code
15
+ # whatever the event date. So a statement names the system, and the system
16
+ # decides by the event date. ["ms_drg", "010"] selects the DRG 010 events
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.
21
+ #
22
+ # Each operator is a vocabulary operator of vocabulary DRG. So its code
23
+ # format is the DRG row of vocabulary_formats (a vocabulary operator uses
24
+ # the row of the vocabulary it selects), and its unknown-code warning
25
+ # checks the DRG vocabulary.
26
+ #
27
+ # The deprecated drg operator selects the events of both systems.
28
+ module DrgSystems
29
+ # The first day of MS-DRG. ms_drg selects the events on or after this
30
+ # day, and cms_drg the events before it.
31
+ MS_DRG_START_DATE = Date.new(2007, 10, 1)
32
+
33
+ # The vocabulary and the operator that hold both systems.
34
+ VOCABULARY_ID = "DRG"
35
+ DRG_OPERATOR = "drg"
36
+
37
+ DRG_DEPRECATION = "drg will become an error in a later release. A DRG code can have two meanings, " \
38
+ "and drg selects the events of both. Use ms_drg for the MS-DRG meaning (events from " \
39
+ "#{MS_DRG_START_DATE.iso8601}) or cms_drg for the CMS-DRG meaning (events before " \
40
+ "#{MS_DRG_START_DATE.iso8601}).".freeze
41
+
42
+ # operator: the operator name. concept_class_id: the OHDSI concept class
43
+ # of the system's meanings. from_cutover: true when the system starts on
44
+ # MS_DRG_START_DATE, false when it ends the day before.
45
+ System = Data.define(:operator, :name, :long_name, :concept_class_id, :from_cutover) do
46
+ # The condition on the event's start date.
47
+ def date_condition(start_date_column)
48
+ column = Sequel.identifier(start_date_column)
49
+ from_cutover ? column >= MS_DRG_START_DATE : column < MS_DRG_START_DATE
50
+ end
51
+ end
52
+
53
+ SYSTEMS = [
54
+ System.new(operator: "ms_drg", name: "MS-DRG",
55
+ long_name: "Medicare Severity Diagnosis-Related Group (CMS), events from " \
56
+ "#{MS_DRG_START_DATE.iso8601}",
57
+ concept_class_id: "MS-DRG", from_cutover: true),
58
+ System.new(operator: "cms_drg", name: "CMS-DRG",
59
+ long_name: "CMS Diagnosis-Related Group, events before #{MS_DRG_START_DATE.iso8601}",
60
+ concept_class_id: "DRG", from_cutover: false)
61
+ ].to_h { |system| [ system.operator, system ] }.freeze
62
+
63
+ class << self
64
+ def system(operator)
65
+ SYSTEMS[operator]
66
+ end
67
+
68
+ # The vocabulary entries of ms_drg and cms_drg, as id => Entry. Each
69
+ # copies the entry of drg (its domain, for example) and renames it.
70
+ # Without a drg entry there is none.
71
+ def entries(drg_entry)
72
+ return {} unless drg_entry
73
+
74
+ SYSTEMS.transform_values do |system|
75
+ drg_entry.merge(Entry.new(id: system.operator,
76
+ omopv5_vocabulary_id: VOCABULARY_ID,
77
+ preferred_name: system.name,
78
+ vocabulary_short_name: system.name,
79
+ vocabulary_long_name: system.long_name))
80
+ end
81
+ end
82
+ end
83
+
84
+ # The behavior of the ms_drg and cms_drg operator classes.
85
+ module OperatorBehavior
86
+ def drg_system
87
+ DrgSystems.system(vocab_entry.id)
88
+ end
89
+
90
+ # Each code is named by its meaning in the operator's system when the
91
+ # lexicon can tell: the OHDSI vocabulary gives each meaning a concept
92
+ # class (MS-DRG or DRG). A code without a row of that class, and every
93
+ # code under a lexicon that keeps no class (GDM), gets every name of
94
+ # the code, as drg gives.
95
+ def describe_codes(db, codes)
96
+ return super if select_all?
97
+
98
+ in_system = dm.concepts_to_codes_in_class(db, vocabulary_id, drg_system.concept_class_id, codes)
99
+ return super if in_system.blank?
100
+
101
+ named = in_system.map { |code, _| code.downcase }
102
+ rest = codes.reject { |code| named.include?(code.downcase) }
103
+ in_system + (rest.empty? ? [] : super(db, rest))
104
+ end
105
+
106
+ # The operator selects one meaning of each code, so a code that the
107
+ # vocabulary reused across the two systems needs no warning.
108
+ def warn_about_reused_codes(_db, _codes); end
109
+
110
+ private
111
+
112
+ def where_clause(db)
113
+ Sequel.&(super, drg_system.date_condition(dm.start_date_column(db, criterion_table)))
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "csv"
4
4
  require_relative "entry"
5
+ require_relative "drg_systems"
5
6
 
6
7
  module ConceptQL
7
8
  module Vocabularies
@@ -26,11 +27,16 @@ module ConceptQL
26
27
  end
27
28
  end
28
29
 
30
+ # Every vocabulary entry, as id => Entry, and the entries of ms_drg and
31
+ # cms_drg, which copy the entry of drg (DrgSystems).
29
32
  def all_vocabs
30
- @all_vocabs ||= each_vocab.each_with_object({}) do |row, h|
31
- entry = Entry.new(row.to_hash.compact)
32
- h[entry.id] ||= entry
33
- h[entry.id] = h[entry.id].merge(entry)
33
+ @all_vocabs ||= begin
34
+ vocabs = each_vocab.each_with_object({}) do |row, h|
35
+ entry = Entry.new(row.to_hash.compact)
36
+ h[entry.id] ||= entry
37
+ h[entry.id] = h[entry.id].merge(entry)
38
+ end
39
+ vocabs.merge(DrgSystems.entries(vocabs[DrgSystems::DRG_OPERATOR]))
34
40
  end
35
41
  end
36
42
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "formats"
3
4
  require_relative "vocabulary_class_factory"
4
5
 
5
6
  module ConceptQL
@@ -9,12 +10,10 @@ module ConceptQL
9
10
 
10
11
  METHODS = %i[
11
12
  id
12
- omopv4_vocabulary_id
13
13
  omopv5_vocabulary_id
14
14
  domain
15
15
  vocabulary_short_name
16
16
  vocabulary_long_name
17
- format_regexp
18
17
  ].freeze
19
18
 
20
19
  METHODS.each do |meth|
@@ -24,15 +23,11 @@ module ConceptQL
24
23
  end
25
24
 
26
25
  METADATA_METHODS = METHODS + %i[
26
+ format_regexp
27
27
  preferred_name
28
28
  predominant_domains
29
29
  ]
30
30
 
31
- COST_RELATED_VOCABS = [
32
- "Revenue Code",
33
- "DRG"
34
- ].freeze
35
-
36
31
  def initialize(hash)
37
32
  @hash = hash
38
33
  @hash[:omopv5_vocabulary_id] ||= @hash[:id]
@@ -54,18 +49,20 @@ module ConceptQL
54
49
  self.class.new(hash.merge(other_entry.hash))
55
50
  end
56
51
 
57
- def belongs_in_omopv4_plus?
58
- @belongs_in_omopv4_plus ||= (!from_lexicon? || from_csv?) \
59
- && has_domain? \
60
- && visible?
61
- end
62
-
63
52
  def to_hash
64
53
  METADATA_METHODS.to_h do |meth|
65
54
  [ meth, send(meth) ]
66
55
  end
67
56
  end
68
57
 
58
+ # The anchored code format from vocabulary_formats, as a string, or nil
59
+ # when vocabulary_formats has no row for this vocabulary. A
60
+ # format_regexp column in a vocabularies CSV is ignored: vocabulary_formats
61
+ # is the only source (bead conceptql-bns).
62
+ def format_regexp
63
+ Formats.pattern(omopv5_id)
64
+ end
65
+
69
66
  def short_name
70
67
  vocabulary_short_name || vocabulary_long_name
71
68
  end
@@ -74,16 +71,19 @@ module ConceptQL
74
71
  vocabulary_long_name || vocabulary_short_name
75
72
  end
76
73
 
74
+ # A preferred_name in the hash wins: ms_drg and cms_drg select vocabulary
75
+ # DRG, but each is named after its own DRG system.
77
76
  def preferred_name
78
- omopv5_id || id
77
+ hash[:preferred_name] || omopv5_id || id
79
78
  end
80
79
 
81
80
  def aliases
82
81
  @hash[:aliases_arr]
83
82
  end
84
83
 
84
+ # A vocabulary without a domain selects condition_occurrence records
85
85
  def predominant_domains
86
- Array(domain || :condition_occurrence).flatten
86
+ Array(domain || "condition_occurrence").flatten
87
87
  end
88
88
 
89
89
  def from_lexicon?
@@ -114,10 +114,6 @@ module ConceptQL
114
114
  hash[:domain] =~ /drug_exposure/i
115
115
  end
116
116
 
117
- def is_costish?
118
- belongs_in_omopv4_plus? && COST_RELATED_VOCABS.include?(omopv5_vocabulary_id)
119
- end
120
-
121
117
  def visible?
122
118
  !hidden?
123
119
  end
@@ -126,10 +122,6 @@ module ConceptQL
126
122
  omopv5_vocabulary_id || id
127
123
  end
128
124
 
129
- def omopv4_id
130
- omopv4_vocabulary_id
131
- end
132
-
133
125
  def get_klasses
134
126
  VocabularyClassFactory.new(self).get_klasses
135
127
  end