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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +498 -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 +2091 -0
- data/lib/conceptql/behaviors/metadatable.rb +15 -3
- data/lib/conceptql/behaviors/provenanceable.rb +82 -8
- data/lib/conceptql/behaviors/utilizable.rb +5 -3
- data/lib/conceptql/cli.rb +1 -1
- data/lib/conceptql/data_model/base.rb +3 -171
- data/lib/conceptql/data_model/gdm.rb +3 -1
- data/lib/conceptql/data_model.rb +20 -4
- data/lib/conceptql/database.rb +9 -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 +58 -4
- data/lib/conceptql/lexicon.rb +218 -2
- data/lib/conceptql/nodifier.rb +7 -1
- data/lib/conceptql/one_query_counts.rb +120 -0
- data/lib/conceptql/operators/co_reported.rb +4 -31
- 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 +2 -3
- data/lib/conceptql/operators/hospitalization.rb +2 -3
- data/lib/conceptql/operators/information_periods.rb +5 -4
- 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/one_in_two_out.rb +1 -2
- data/lib/conceptql/operators/operator.rb +66 -30
- 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 +13 -4
- data/lib/conceptql/operators/provider_filter.rb +5 -1
- data/lib/conceptql/operators/race.rb +2 -10
- data/lib/conceptql/operators/read.rb +1 -100
- data/lib/conceptql/operators/recall.rb +1 -1
- data/lib/conceptql/operators/snf.rb +3 -4
- 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 +21 -4
- data/lib/conceptql/paths.rb +16 -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 -23
- data/lib/conceptql/vocabularies/formats.rb +59 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
- data/lib/conceptql/window/table.rb +12 -3
- metadata +9 -14
- data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
- data/lib/conceptql/operators/concurrent_within.rb +0 -69
- data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
- data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
- data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
- data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
- data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
- data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
- data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
- data/schemas/omopv4_plus.yml +0 -729
- 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
|
|
37
|
+
ds = if cols.include?(:provider_id)
|
|
34
38
|
ds
|
|
35
|
-
elsif
|
|
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 =
|
|
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
|
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
|
|
@@ -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 ||
|
|
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
|