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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +454 -0
- data/config/vocabularies.csv +85 -87
- data/config/vocabulary_formats.sha +1 -0
- data/config/vocabulary_formats.tsv +39 -0
- data/doc/spec.md +207 -67
- data/lib/conceptql/behaviors/metadatable.rb +15 -3
- data/lib/conceptql/behaviors/provenanceable.rb +82 -8
- data/lib/conceptql/behaviors/utilizable.rb +5 -1
- data/lib/conceptql/data_model/base.rb +2 -4
- data/lib/conceptql/data_model/gdm.rb +3 -1
- data/lib/conceptql/database.rb +7 -2
- data/lib/conceptql/date_adjuster.rb +12 -5
- data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
- data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
- data/lib/conceptql/lexicon/lexicon_strategy.rb +35 -1
- data/lib/conceptql/lexicon.rb +210 -2
- data/lib/conceptql/nodifier.rb +7 -1
- data/lib/conceptql/one_query_counts.rb +120 -0
- data/lib/conceptql/operators/count.rb +4 -0
- data/lib/conceptql/operators/death.rb +1 -1
- data/lib/conceptql/operators/dedup.rb +1 -1
- data/lib/conceptql/operators/episode.rb +10 -2
- data/lib/conceptql/operators/ethnicity.rb +0 -4
- data/lib/conceptql/operators/except.rb +5 -0
- data/lib/conceptql/operators/filter.rb +12 -2
- data/lib/conceptql/operators/hospice.rb +1 -1
- data/lib/conceptql/operators/hospitalization.rb +1 -1
- data/lib/conceptql/operators/information_periods.rb +4 -1
- data/lib/conceptql/operators/invalid.rb +7 -1
- data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
- data/lib/conceptql/operators/numeric.rb +51 -14
- data/lib/conceptql/operators/occurrence.rb +16 -4
- data/lib/conceptql/operators/operator.rb +63 -19
- data/lib/conceptql/operators/person.rb +1 -1
- data/lib/conceptql/operators/person_filter.rb +5 -0
- data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
- data/lib/conceptql/operators/provenance.rb +12 -2
- data/lib/conceptql/operators/provider_filter.rb +5 -1
- data/lib/conceptql/operators/race.rb +0 -4
- data/lib/conceptql/operators/recall.rb +1 -1
- data/lib/conceptql/operators/snf.rb +2 -2
- data/lib/conceptql/operators/sum.rb +46 -5
- data/lib/conceptql/operators/temporal_operator.rb +6 -2
- data/lib/conceptql/operators/trim_date.rb +52 -6
- data/lib/conceptql/operators/trim_date_end.rb +11 -1
- data/lib/conceptql/operators/trim_date_start.rb +11 -1
- data/lib/conceptql/operators/union.rb +45 -1
- data/lib/conceptql/operators/vocabulary.rb +7 -3
- data/lib/conceptql/paths.rb +10 -0
- data/lib/conceptql/query.rb +78 -24
- data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
- data/lib/conceptql/rdbms/generic.rb +21 -1
- data/lib/conceptql/rdbms/spark.rb +12 -0
- data/lib/conceptql/scope.rb +187 -24
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
- data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
- data/lib/conceptql/vocabularies/entry.rb +15 -3
- data/lib/conceptql/vocabularies/formats.rb +59 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
- data/lib/conceptql/window/table.rb +12 -3
- metadata +8 -4
- data/lib/conceptql/operators/concurrent_within.rb +0 -69
data/lib/conceptql/scope.rb
CHANGED
|
@@ -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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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 =
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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)
|
data/lib/conceptql/version.rb
CHANGED
|
@@ -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 ||=
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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
|