conceptql 0.5.0 → 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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +454 -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 +207 -67
  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 -1
  10. data/lib/conceptql/data_model/base.rb +2 -4
  11. data/lib/conceptql/data_model/gdm.rb +3 -1
  12. data/lib/conceptql/database.rb +7 -2
  13. data/lib/conceptql/date_adjuster.rb +12 -5
  14. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  15. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  16. data/lib/conceptql/lexicon/lexicon_strategy.rb +35 -1
  17. data/lib/conceptql/lexicon.rb +210 -2
  18. data/lib/conceptql/nodifier.rb +7 -1
  19. data/lib/conceptql/one_query_counts.rb +120 -0
  20. data/lib/conceptql/operators/count.rb +4 -0
  21. data/lib/conceptql/operators/death.rb +1 -1
  22. data/lib/conceptql/operators/dedup.rb +1 -1
  23. data/lib/conceptql/operators/episode.rb +10 -2
  24. data/lib/conceptql/operators/ethnicity.rb +0 -4
  25. data/lib/conceptql/operators/except.rb +5 -0
  26. data/lib/conceptql/operators/filter.rb +12 -2
  27. data/lib/conceptql/operators/hospice.rb +1 -1
  28. data/lib/conceptql/operators/hospitalization.rb +1 -1
  29. data/lib/conceptql/operators/information_periods.rb +4 -1
  30. data/lib/conceptql/operators/invalid.rb +7 -1
  31. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  32. data/lib/conceptql/operators/numeric.rb +51 -14
  33. data/lib/conceptql/operators/occurrence.rb +16 -4
  34. data/lib/conceptql/operators/operator.rb +63 -19
  35. data/lib/conceptql/operators/person.rb +1 -1
  36. data/lib/conceptql/operators/person_filter.rb +5 -0
  37. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  38. data/lib/conceptql/operators/provenance.rb +12 -2
  39. data/lib/conceptql/operators/provider_filter.rb +5 -1
  40. data/lib/conceptql/operators/race.rb +0 -4
  41. data/lib/conceptql/operators/recall.rb +1 -1
  42. data/lib/conceptql/operators/snf.rb +2 -2
  43. data/lib/conceptql/operators/sum.rb +46 -5
  44. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  45. data/lib/conceptql/operators/trim_date.rb +52 -6
  46. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  47. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  48. data/lib/conceptql/operators/union.rb +45 -1
  49. data/lib/conceptql/operators/vocabulary.rb +7 -3
  50. data/lib/conceptql/paths.rb +10 -0
  51. data/lib/conceptql/query.rb +78 -24
  52. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  53. data/lib/conceptql/rdbms/generic.rb +21 -1
  54. data/lib/conceptql/rdbms/spark.rb +12 -0
  55. data/lib/conceptql/scope.rb +187 -24
  56. data/lib/conceptql/version.rb +1 -1
  57. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  58. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  59. data/lib/conceptql/vocabularies/entry.rb +15 -3
  60. data/lib/conceptql/vocabularies/formats.rb +59 -0
  61. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
  62. data/lib/conceptql/window/table.rb +12 -3
  63. metadata +8 -4
  64. data/lib/conceptql/operators/concurrent_within.rb +0 -69
@@ -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.5.0"
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
@@ -13,7 +14,6 @@ module ConceptQL
13
14
  domain
14
15
  vocabulary_short_name
15
16
  vocabulary_long_name
16
- format_regexp
17
17
  ].freeze
18
18
 
19
19
  METHODS.each do |meth|
@@ -23,6 +23,7 @@ module ConceptQL
23
23
  end
24
24
 
25
25
  METADATA_METHODS = METHODS + %i[
26
+ format_regexp
26
27
  preferred_name
27
28
  predominant_domains
28
29
  ]
@@ -54,6 +55,14 @@ module ConceptQL
54
55
  end
55
56
  end
56
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
+
57
66
  def short_name
58
67
  vocabulary_short_name || vocabulary_long_name
59
68
  end
@@ -62,16 +71,19 @@ module ConceptQL
62
71
  vocabulary_long_name || vocabulary_short_name
63
72
  end
64
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.
65
76
  def preferred_name
66
- omopv5_id || id
77
+ hash[:preferred_name] || omopv5_id || id
67
78
  end
68
79
 
69
80
  def aliases
70
81
  @hash[:aliases_arr]
71
82
  end
72
83
 
84
+ # A vocabulary without a domain selects condition_occurrence records
73
85
  def predominant_domains
74
- Array(domain || :condition_occurrence).flatten
86
+ Array(domain || "condition_occurrence").flatten
75
87
  end
76
88
 
77
89
  def from_lexicon?
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require_relative "../paths"
5
+
6
+ module ConceptQL
7
+ module Vocabularies
8
+ # The code formats that a Vocabulary operator checks its codes against.
9
+ # They come from config/vocabulary_formats.tsv, a pinned copy of the
10
+ # published table of the public repository
11
+ # outcomesinsights/vocabulary_formats (bead conceptql-bns, seed
12
+ # conceptql-ysx). config/vocabulary_formats.sha records the commit, and
13
+ # `just vocabulary-formats <ref>` refreshes both.
14
+ #
15
+ # Only the DEFAULT row of a vocabulary is used: the row whose vocabulary_id
16
+ # is the vocabulary's id. A variant row (ICD9CM.DOTLESS) is never used. The
17
+ # published patterns carry no anchors, so each one is anchored here as
18
+ # \A(?:pattern)\z. A vocabulary with no row has no format to check.
19
+ #
20
+ # A pattern is case-exact unless its row declares case-insensitivity with
21
+ # the scoped flag (?i:...) (vocabulary_formats seed vfm-8k1, seed
22
+ # conceptql-oji ruling "b"). Ruby honors the flag natively, so a pattern
23
+ # is compiled with no flag of its own: an ignore-case flag would override
24
+ # what the row says.
25
+ module Formats
26
+ DECLARES_CASE_INSENSITIVE = "(?i:"
27
+
28
+ class << self
29
+ # The anchored pattern of a vocabulary as a string, or nil. The
30
+ # vocabulary_id finds its row in any case.
31
+ def pattern(vocabulary_id)
32
+ patterns[vocabulary_id.to_s.downcase]
33
+ end
34
+
35
+ # The anchored pattern as a Regexp, or nil.
36
+ def regexp(vocabulary_id)
37
+ (str = pattern(vocabulary_id)) && Regexp.new(str)
38
+ end
39
+
40
+ # True when the vocabulary's row declares case-insensitivity with
41
+ # (?i:...). The lexicon then matches the vocabulary's codes without
42
+ # regard to case (LexiconStrategy#code_condition). A vocabulary with no
43
+ # row is case-exact.
44
+ def case_insensitive?(vocabulary_id)
45
+ pattern(vocabulary_id).to_s.include?(DECLARES_CASE_INSENSITIVE)
46
+ end
47
+
48
+ private
49
+
50
+ def patterns
51
+ @patterns ||= CSV.foreach(ConceptQL.vocabulary_formats_file_path, col_sep: "\t", headers: true)
52
+ .reject { |row| row["vocabulary_id"].include?(".") }
53
+ .to_h { |row| [ row["vocabulary_id"].downcase, "\\A(?:#{row.fetch("regexp")})\\z" ] }
54
+ .freeze
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../behaviors/drugish"
4
4
  require_relative "../behaviors/labish"
5
5
  require_relative "behaviors/gdmish"
6
+ require_relative "drg_systems"
6
7
  require_relative "../operators/vocabulary"
7
8
 
8
9
  module ConceptQL
@@ -39,7 +40,7 @@ module ConceptQL
39
40
  preferred_name entry.preferred_name
40
41
  argument :codes, type: :codelist
41
42
  aliases entry.aliases
42
- predominant_domains entry.predominant_domains
43
+ predominant_domains(*entry.predominant_domains)
43
44
  short_name entry.short_name
44
45
  long_name entry.long_name
45
46
  conceptql_spec_id "vocabulary"
@@ -62,6 +63,10 @@ module ConceptQL
62
63
 
63
64
  include ConceptQL::Behaviors::Drugish if entry.is_drugish?
64
65
 
66
+ include DrgSystems::OperatorBehavior if DrgSystems.system(entry.id)
67
+
68
+ deprecated DrgSystems::DRG_DEPRECATION if entry.id == DrgSystems::DRG_OPERATOR
69
+
65
70
  def vocab_entry
66
71
  self.class.entry
67
72
  end
@@ -25,8 +25,11 @@ module ConceptQL
25
25
 
26
26
  rhs = query.db[get_table_window(query)]
27
27
 
28
+ # select_group replaces the select list, so rhs needs no
29
+ # remove_window_id. Its select_remove asked the database for the
30
+ # columns of the window table while the SQL was built (bead
31
+ # conceptql-6k8).
28
32
  unless opts[:precalculated_window_id]
29
- rhs = remove_window_id(rhs)
30
33
  rhs = rhs
31
34
  .select_group(:person_id, :start_date, :end_date)
32
35
  .select_append do
@@ -37,7 +40,7 @@ module ConceptQL
37
40
 
38
41
  rhs = rhs.as(:r)
39
42
 
40
- remove_window_id(query)
43
+ remove_window_id(query, op.dynamic_columns)
41
44
  .from_self(alias: :l)
42
45
  .join(rhs, expr)
43
46
  .select_all(:l)
@@ -52,12 +55,18 @@ module ConceptQL
52
55
  db[get_table_window(nil)].select(:person_id)
53
56
  end
54
57
 
55
- def remove_window_id(ds)
58
+ # columns: the names of the columns of ds, when the caller knows them.
59
+ # For a ds without a select list, select_remove asks the database for
60
+ # them, and a table that ds reads may not exist while the SQL is built
61
+ # (bead conceptql-6k8).
62
+ def remove_window_id(ds, columns = nil)
56
63
  cols = ds.opts[:select]
57
64
  if cols && !cols.empty? && cols.any? { |c| !c.is_a?(Symbol) }
58
65
  # When select has non-symbol expressions (e.g. CAST...AS),
59
66
  # select_remove can't match them by symbol name, so filter manually
60
67
  ds.select(*cols.reject { |c| window_id_column?(c) })
68
+ elsif columns && cols.blank?
69
+ ds.select(*(columns - [ :window_id ]))
61
70
  else
62
71
  ds.select_remove(:window_id)
63
72
  end