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
|
@@ -26,8 +26,7 @@ module ConceptQL
|
|
|
26
26
|
|
|
27
27
|
origin_column = column
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
while (chr = chrs.shift) =~ /[res]/i
|
|
29
|
+
prefix.each_char do |chr|
|
|
31
30
|
if chr.downcase == "r"
|
|
32
31
|
reverse = true
|
|
33
32
|
else
|
|
@@ -53,13 +52,20 @@ module ConceptQL
|
|
|
53
52
|
op.rdbms.cast_date(adjusted_date)
|
|
54
53
|
end
|
|
55
54
|
|
|
56
|
-
# Returns an array of
|
|
55
|
+
# Returns an array of [units, quantity] pairs that represent date modifiers.
|
|
56
|
+
# An "r" in the prefix reverses them, as it does in #adjust.
|
|
57
57
|
def adjustments(reverse = false)
|
|
58
|
-
|
|
58
|
+
reverse ||= prefix.match?(/r/i)
|
|
59
|
+
(@adjustments ||= {})[reverse] ||= parse(str.delete_prefix(prefix), reverse)
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
private
|
|
62
63
|
|
|
64
|
+
# The leading e/r/s characters, which pick the origin column and the direction
|
|
65
|
+
def prefix
|
|
66
|
+
str[/\A[res]*/i]
|
|
67
|
+
end
|
|
68
|
+
|
|
63
69
|
def lookup
|
|
64
70
|
{
|
|
65
71
|
"y" => :years,
|
|
@@ -71,7 +77,8 @@ module ConceptQL
|
|
|
71
77
|
|
|
72
78
|
def parse(str, reverse)
|
|
73
79
|
return [] if str.nil? || str.empty?
|
|
74
|
-
|
|
80
|
+
# A bare number means days
|
|
81
|
+
return [ [ lookup["d"], reverse ? -str.to_i : str.to_i ] ] if str.match?(/\A[-+]?\d+\z/)
|
|
75
82
|
|
|
76
83
|
str.downcase.scan(/([-+]?\d*[dwmy])/).map do |adjustment|
|
|
77
84
|
adjustment = adjustment.first
|
|
@@ -4,9 +4,12 @@ require_relative "lexicon_strategy"
|
|
|
4
4
|
|
|
5
5
|
module ConceptQL
|
|
6
6
|
class LexiconGDM < LexiconStrategy
|
|
7
|
+
# The GDM vocabulary tables. A db that has all four can be the lexicon.
|
|
8
|
+
VOCABULARY_TABLES = %i[ancestors concepts mappings vocabularies].freeze
|
|
9
|
+
|
|
7
10
|
class << self
|
|
8
11
|
def db_has_all_vocabulary_tables?(db)
|
|
9
|
-
|
|
12
|
+
VOCABULARY_TABLES.all? { |t| db.table_exists?(t) }
|
|
10
13
|
end
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -4,9 +4,12 @@ require_relative "lexicon_strategy"
|
|
|
4
4
|
|
|
5
5
|
module ConceptQL
|
|
6
6
|
class LexiconOhdsi < LexiconStrategy
|
|
7
|
+
# The OHDSI vocabulary tables. A db that has all four can be the lexicon.
|
|
8
|
+
VOCABULARY_TABLES = %i[concept concept_ancestor concept_relationship vocabulary].freeze
|
|
9
|
+
|
|
7
10
|
class << self
|
|
8
11
|
def db_has_all_vocabulary_tables?(db)
|
|
9
|
-
|
|
12
|
+
VOCABULARY_TABLES.all? { |t| db.table_exists?(t) }
|
|
10
13
|
end
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -56,6 +59,21 @@ module ConceptQL
|
|
|
56
59
|
!db.table_exists?(:concept)
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
# A caller can answer "is the concept table missing?" with a block. A
|
|
63
|
+
# concept table without concept_class_id answers nil, as a lexicon
|
|
64
|
+
# without classes does.
|
|
65
|
+
def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes)
|
|
66
|
+
return nil if codes.blank? || db_is_mock?(data_db)
|
|
67
|
+
return nil if block_given? ? yield : table_is_missing?(data_db)
|
|
68
|
+
|
|
69
|
+
db[:concept]
|
|
70
|
+
.where(vocabulary_id: vocabulary_id, concept_class_id: concept_class_id)
|
|
71
|
+
.where(code_condition(vocabulary_id, codes))
|
|
72
|
+
.select_map(%i[concept_code concept_name])
|
|
73
|
+
rescue Sequel::DatabaseError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
59
77
|
def strategy
|
|
60
78
|
:ohdsi
|
|
61
79
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../vocabularies/formats"
|
|
4
|
+
|
|
3
5
|
module ConceptQL
|
|
4
6
|
class LexiconStrategy
|
|
5
7
|
attr_reader :db
|
|
@@ -28,11 +30,35 @@ module ConceptQL
|
|
|
28
30
|
ds = concepts_table(data_db)
|
|
29
31
|
|
|
30
32
|
ds = ds.where(vocabulary_id: vocabulary_id)
|
|
31
|
-
ds = ds.where(
|
|
33
|
+
ds = ds.where(code_condition(vocabulary_id, codes)) unless codes.blank?
|
|
32
34
|
|
|
33
35
|
ds
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# The condition that matches concept_code with codes. It compares case
|
|
39
|
+
# exactly, unless the vocabulary's vocabulary_formats row declares
|
|
40
|
+
# case-insensitivity with (?i:...) (seed conceptql-oji ruling "b"). Then it
|
|
41
|
+
# compares lower(concept_code) with the codes in lower case. One lookup
|
|
42
|
+
# over several vocabularies has one comparison, so they must agree.
|
|
43
|
+
def code_condition(vocabulary_id, codes)
|
|
44
|
+
codes = Array(codes)
|
|
45
|
+
return { concept_code: codes } unless case_insensitive?(vocabulary_id)
|
|
46
|
+
|
|
47
|
+
{ Sequel.function(:lower, :concept_code) => codes.map(&:downcase) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def case_insensitive?(vocabulary_id)
|
|
51
|
+
ids = Array(vocabulary_id)
|
|
52
|
+
declared = ids.select { |id| Vocabularies::Formats.case_insensitive?(id) }
|
|
53
|
+
return false if declared.empty?
|
|
54
|
+
return true if declared.size == ids.size
|
|
55
|
+
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"one lookup over the vocabularies #{ids.inspect} cannot compare case: " \
|
|
58
|
+
"#{declared.inspect} declare case-insensitivity with (?i:...) in vocabulary_formats, " \
|
|
59
|
+
"and #{(ids - declared).inspect} do not"
|
|
60
|
+
end
|
|
61
|
+
|
|
36
62
|
def concepts_by_name(data_db, names = [])
|
|
37
63
|
ds = concepts_table(data_db)
|
|
38
64
|
|
|
@@ -118,6 +144,14 @@ module ConceptQL
|
|
|
118
144
|
{}
|
|
119
145
|
end
|
|
120
146
|
|
|
147
|
+
# The [code, name] pairs of the codes whose concept is in one concept
|
|
148
|
+
# class, or nil when the lexicon keeps no concept classes. Only the OHDSI
|
|
149
|
+
# vocabulary keeps them (LexiconOhdsi). ms_drg and cms_drg name a code by
|
|
150
|
+
# its meaning in their own DRG system with it (DrgSystems).
|
|
151
|
+
def concepts_to_codes_in_class(_data_db, _vocabulary_id, _concept_class_id, _codes)
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
121
155
|
def vocabulary_is_empty?(data_db, vocabulary_id)
|
|
122
156
|
concepts_table(data_db).where(vocabulary_id: vocabulary_id).count.zero?
|
|
123
157
|
end
|
data/lib/conceptql/lexicon.rb
CHANGED
|
@@ -19,6 +19,7 @@ module ConceptQL
|
|
|
19
19
|
concept_ids
|
|
20
20
|
concept_ids_by_name
|
|
21
21
|
concepts_to_codes
|
|
22
|
+
concepts_to_codes_in_class
|
|
22
23
|
descendants_of
|
|
23
24
|
known_codes
|
|
24
25
|
related_concept_ids
|
|
@@ -35,16 +36,158 @@ module ConceptQL
|
|
|
35
36
|
:vocabularies,
|
|
36
37
|
:vocabularies_query
|
|
37
38
|
|
|
39
|
+
# The key of each lexicon kind in NoVocabularyError::Search#missing_tables.
|
|
40
|
+
LEXICON_KINDS = { LexiconOhdsi => :ohdsi, LexiconGDM => :gdm }.freeze
|
|
41
|
+
|
|
38
42
|
# The cached results keyed on [method, arguments], or nil when caching is
|
|
39
43
|
# off. Its size is the number of distinct lookups.
|
|
40
44
|
attr_reader :cache
|
|
41
45
|
|
|
46
|
+
# No database that ConceptQL searched has the vocabulary tables. Without
|
|
47
|
+
# them every code compiles to 1 = 0 and the unknown-code checks turn off,
|
|
48
|
+
# so a query on real data would return no rows without a word.
|
|
49
|
+
#
|
|
50
|
+
# ConceptQL::Database raises it (through Lexicon.new) when its data
|
|
51
|
+
# database is real, which is not nil and not a Sequel mock, and neither
|
|
52
|
+
# that database nor the LEXICON_URL database has all the tables of one
|
|
53
|
+
# lexicon kind. A database-less Database keeps LexiconNoDB.
|
|
54
|
+
# MissingVocabularyError (Lexicon.for_dataset) is a subclass, so one
|
|
55
|
+
# rescue covers both.
|
|
56
|
+
class NoVocabularyError < StandardError
|
|
57
|
+
# One database that was searched. +role+ is :data_database (the
|
|
58
|
+
# Database's own db), :lexicon_url (the LEXICON_URL database) or
|
|
59
|
+
# :dataset (Lexicon.for_dataset). +database+ names it without a user or
|
|
60
|
+
# a password. +search_path+ is the search path that the unqualified
|
|
61
|
+
# table names resolved through, or nil where the adapter has none.
|
|
62
|
+
# +missing_tables+ maps each lexicon kind (:ohdsi, :gdm) that was
|
|
63
|
+
# looked for to its tables that were not found.
|
|
64
|
+
Search = Data.define(:role, :database, :search_path, :missing_tables)
|
|
65
|
+
|
|
66
|
+
ROLES = {
|
|
67
|
+
data_database: "the data database",
|
|
68
|
+
lexicon_url: "the LEXICON_URL database",
|
|
69
|
+
dataset: "the DuckDB dataset"
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
attr_reader :searches
|
|
73
|
+
|
|
74
|
+
def initialize(message = nil, searches: [])
|
|
75
|
+
@searches = searches
|
|
76
|
+
super(message)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The error for a ConceptQL::Database whose data database is real.
|
|
80
|
+
# +database_type+ is the data database's, and chooses the advice.
|
|
81
|
+
# +lexicon_url_searched+ is false when LEXICON_URL is not set.
|
|
82
|
+
# +only+ is the LEXICON_*_ONLY variable that narrowed the kinds, or nil.
|
|
83
|
+
def self.for_database(searches, database_type:, lexicon_url_searched:, only: nil)
|
|
84
|
+
data = searches.first
|
|
85
|
+
lines = [
|
|
86
|
+
"ConceptQL::Database found no vocabulary tables for its data database (#{data.database}).",
|
|
87
|
+
"Without them every code would match no rows and unknown codes would not be reported, " \
|
|
88
|
+
"so the Database refuses to start.",
|
|
89
|
+
"It searched:"
|
|
90
|
+
]
|
|
91
|
+
lines.concat(searches.map { |search| " - #{describe_search(search)}" })
|
|
92
|
+
lines << " - LEXICON_URL is not set, so no lexicon database was searched." unless lexicon_url_searched
|
|
93
|
+
lines << "#{only} is set, so only the #{only.delete_prefix("LEXICON_").delete_suffix("_ONLY")} tables were looked for." if only
|
|
94
|
+
lines << "Fix it in one of these ways:"
|
|
95
|
+
lines.concat(advice(database_type).map { |line| " - #{line}" })
|
|
96
|
+
new(lines.join("\n"), searches: searches)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.describe_search(search)
|
|
100
|
+
where = ROLES.fetch(search.role)
|
|
101
|
+
where += " (#{search.database})" unless search.role == :data_database
|
|
102
|
+
where += ", through the search path #{search.search_path.inspect}" if search.search_path
|
|
103
|
+
missing = search.missing_tables.map do |kind, tables|
|
|
104
|
+
"#{kind.to_s.upcase} tables #{tables.join(", ")} are missing"
|
|
105
|
+
end
|
|
106
|
+
"#{where}: #{missing.join("; ")}."
|
|
107
|
+
end
|
|
108
|
+
private_class_method :describe_search
|
|
109
|
+
|
|
110
|
+
def self.advice(database_type)
|
|
111
|
+
dataset = "Build the lexicon from the DuckDB dataset itself: " \
|
|
112
|
+
"ConceptQL::Database.new(db, lexicon: ConceptQL::Lexicon.for_dataset(db))."
|
|
113
|
+
search_path = "Put the schema that holds the vocabulary tables on the data database's " \
|
|
114
|
+
"search path (in the test data, ohdsi_vocabs or gdm_vocabs)."
|
|
115
|
+
lexicon_url = "Set LEXICON_URL to a database that has the vocabulary tables."
|
|
116
|
+
case database_type
|
|
117
|
+
when :duckdb then [ dataset, search_path, lexicon_url ]
|
|
118
|
+
when :postgres then [ search_path, lexicon_url ]
|
|
119
|
+
else [ lexicon_url ]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :advice
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A DuckDB dataset lacks one or more of the OHDSI vocabulary tables, so
|
|
126
|
+
# Lexicon.for_dataset cannot make the dataset its own lexicon.
|
|
127
|
+
class MissingVocabularyError < NoVocabularyError
|
|
128
|
+
attr_reader :missing_tables, :schemas
|
|
129
|
+
|
|
130
|
+
# +missing_tables+ are the vocabulary tables that were not found.
|
|
131
|
+
# +schemas+ are the schemas that were searched for them, each as
|
|
132
|
+
# "catalog.schema", and +search_path+ the DuckDB search path that the
|
|
133
|
+
# unqualified table names resolved through.
|
|
134
|
+
def initialize(missing_tables, schemas:, search_path:)
|
|
135
|
+
@missing_tables = missing_tables
|
|
136
|
+
@schemas = schemas
|
|
137
|
+
search = Search.new(role: :dataset, database: "schemas #{schemas.join(", ")}",
|
|
138
|
+
search_path: search_path, missing_tables: { ohdsi: missing_tables })
|
|
139
|
+
super("The DuckDB dataset (schemas #{schemas.map(&:inspect).join(", ")}) " \
|
|
140
|
+
"has no vocabulary table #{missing_tables.join(", ")}. " \
|
|
141
|
+
"A DuckDB dataset is its own lexicon: ConceptQL reads " \
|
|
142
|
+
"#{LexiconOhdsi::VOCABULARY_TABLES.join(", ")} only from it, and never from LEXICON_URL. " \
|
|
143
|
+
"The tables were looked up without a schema, through the search path #{search_path.inspect}.",
|
|
144
|
+
searches: [ search ])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The lexicon of a DuckDB dataset: the OHDSI vocabulary tables of db
|
|
149
|
+
# itself, found without a schema through db's search path. LEXICON_URL is
|
|
150
|
+
# never read. The Lexicon caches its lookups for its own life. Pass it to
|
|
151
|
+
# ConceptQL::Database.new(db, lexicon: lexicon), without cache_lookups.
|
|
152
|
+
#
|
|
153
|
+
# Raises MissingVocabularyError when db lacks any of the tables, because a
|
|
154
|
+
# DuckDB dataset never falls back to another lexicon.
|
|
155
|
+
#
|
|
156
|
+
# Returns nil for a Postgres or Spark db. The caller then builds its
|
|
157
|
+
# Database without lexicon:, and the Database chooses one as before.
|
|
158
|
+
def self.for_dataset(db)
|
|
159
|
+
return unless db.database_type.to_sym == :duckdb
|
|
160
|
+
|
|
161
|
+
missing = LexiconOhdsi::VOCABULARY_TABLES.reject { |table| db.table_exists?(table) }
|
|
162
|
+
unless missing.empty?
|
|
163
|
+
search_path = db.get(Sequel.function(:current_setting, "search_path"))
|
|
164
|
+
raise MissingVocabularyError.new(missing, schemas: searched_schemas(db, search_path), search_path: search_path)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
new(nil, db, strategy: LexiconOhdsi.new(db), cache: true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The schemas, as "catalog.schema", where DuckDB looks for a table named
|
|
171
|
+
# without a schema. DuckDB reports a set search path in that form. The
|
|
172
|
+
# consumers attach the dataset and search, for example,
|
|
173
|
+
# "memory.main,dataset_<schema>.<schema>" (sequelizer 0.5 with_dataset),
|
|
174
|
+
# so current_schema() alone ("main") would name the wrong schema. With no
|
|
175
|
+
# search path set, DuckDB searches the current schema (and its own system
|
|
176
|
+
# schemas, which hold no dataset tables).
|
|
177
|
+
def self.searched_schemas(db, search_path)
|
|
178
|
+
schemas = search_path.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
179
|
+
return schemas unless schemas.empty?
|
|
180
|
+
|
|
181
|
+
[ "#{db.get(Sequel.function(:current_database))}.#{db.get(Sequel.function(:current_schema))}" ]
|
|
182
|
+
end
|
|
183
|
+
private_class_method :searched_schemas
|
|
184
|
+
|
|
42
185
|
def initialize(lexicon_db, dataset_db = nil, strategy: nil, cache: false)
|
|
43
186
|
@strategy = strategy || determine_strategy(lexicon_db, dataset_db)
|
|
44
187
|
@cache = {} if cache
|
|
45
188
|
end
|
|
46
189
|
|
|
47
|
-
(CACHED_LOOKUPS - %i[code_meanings concepts_to_codes known_codes table_is_missing?]).each do |name|
|
|
190
|
+
(CACHED_LOOKUPS - %i[code_meanings concepts_to_codes concepts_to_codes_in_class known_codes table_is_missing?]).each do |name|
|
|
48
191
|
define_method(name) do |*args|
|
|
49
192
|
lookup(name, args) { @strategy.public_send(name, *args) }
|
|
50
193
|
end
|
|
@@ -66,6 +209,13 @@ module ConceptQL
|
|
|
66
209
|
end
|
|
67
210
|
end
|
|
68
211
|
|
|
212
|
+
# Like concepts_to_codes, it asks about the concept table through the block.
|
|
213
|
+
def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes)
|
|
214
|
+
lookup(:concepts_to_codes_in_class, [ data_db, vocabulary_id, concept_class_id, codes ]) do
|
|
215
|
+
@strategy.concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes) { table_is_missing?(data_db) }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
69
219
|
# Like concepts_to_codes, it asks about the concept table through the block.
|
|
70
220
|
def code_meanings(data_db, vocabulary_id, codes)
|
|
71
221
|
lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
|
|
@@ -98,17 +248,75 @@ module ConceptQL
|
|
|
98
248
|
end
|
|
99
249
|
private :lookup
|
|
100
250
|
|
|
251
|
+
# The first lexicon kind whose tables are all in the data db (dataset_db)
|
|
252
|
+
# or else the lexicon db. When neither has them, LexiconNoDB, but only
|
|
253
|
+
# for a database-less caller: a real data db raises NoVocabularyError.
|
|
254
|
+
# A Sequel mock data db is not searched: it answers yes to every
|
|
255
|
+
# table_exists?, so it would hide the lexicon db behind a vocabulary
|
|
256
|
+
# that has no rows.
|
|
101
257
|
def determine_strategy(lexicon_db, dataset_db)
|
|
102
|
-
[ dataset_db, lexicon_db ].compact.each do |db|
|
|
258
|
+
[ (dataset_db if real_database?(dataset_db)), lexicon_db ].compact.each do |db|
|
|
103
259
|
lexicon_classes.each do |klass|
|
|
104
260
|
next unless klass.db_has_all_vocabulary_tables?(db)
|
|
105
261
|
|
|
106
262
|
return (klass.new(db))
|
|
107
263
|
end
|
|
108
264
|
end
|
|
265
|
+
raise no_vocabulary_error(lexicon_db, dataset_db) if real_database?(dataset_db)
|
|
266
|
+
|
|
109
267
|
LexiconNoDB.new(Sequel.mock(host: :postgres))
|
|
110
268
|
end
|
|
111
269
|
|
|
270
|
+
# Not nil and not a Sequel mock: the diagram renderer and SQL rendering
|
|
271
|
+
# use those, and they have no vocabulary on purpose.
|
|
272
|
+
def real_database?(db)
|
|
273
|
+
!db.nil? && db.adapter_scheme != :mock
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def no_vocabulary_error(lexicon_db, dataset_db)
|
|
277
|
+
searches = [ search(:data_database, dataset_db) ]
|
|
278
|
+
searches << search(:lexicon_url, lexicon_db) if lexicon_db
|
|
279
|
+
only = %w[LEXICON_OHDSI_ONLY LEXICON_GDM_ONLY].find { |name| ENV[name] }
|
|
280
|
+
NoVocabularyError.for_database(searches, database_type: dataset_db.database_type.to_sym,
|
|
281
|
+
lexicon_url_searched: !lexicon_db.nil?, only: only)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def search(role, db)
|
|
285
|
+
missing = lexicon_classes.to_h do |klass|
|
|
286
|
+
[ LEXICON_KINDS.fetch(klass), klass::VOCABULARY_TABLES.reject { |table| db.table_exists?(table) } ]
|
|
287
|
+
end
|
|
288
|
+
NoVocabularyError::Search.new(role: role, database: describe_database(db),
|
|
289
|
+
search_path: search_path_of(db), missing_tables: missing)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The database's type, name and host, never its user or password. A
|
|
293
|
+
# DuckDB database is named by its file, which DuckDB reports itself:
|
|
294
|
+
# Sequel's URL parsing drops the leading "/" of a duckdb:///path.
|
|
295
|
+
def describe_database(db)
|
|
296
|
+
name = db.opts[:database]
|
|
297
|
+
if db.database_type.to_sym == :duckdb
|
|
298
|
+
name = db.from(Sequel.function(:duckdb_databases))
|
|
299
|
+
.where(database_name: Sequel.function(:current_database))
|
|
300
|
+
.get(:path) || ":memory:"
|
|
301
|
+
end
|
|
302
|
+
host = db.opts[:host].to_s
|
|
303
|
+
[ "#{db.database_type} database",
|
|
304
|
+
(name.to_s.inspect if name),
|
|
305
|
+
("on host #{host.inspect}" unless host.empty?) ].compact.join(" ")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The search path that a table named without a schema resolves through:
|
|
309
|
+
# Postgres's search_path setting, DuckDB's searched schemas as
|
|
310
|
+
# "catalog.schema", or nil for an adapter without one.
|
|
311
|
+
def search_path_of(db)
|
|
312
|
+
case db.database_type.to_sym
|
|
313
|
+
when :postgres then db.get(Sequel.function(:current_setting, "search_path"))
|
|
314
|
+
when :duckdb
|
|
315
|
+
self.class.send(:searched_schemas, db, db.get(Sequel.function(:current_setting, "search_path"))).join(",")
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
private :real_database?, :no_vocabulary_error, :search, :describe_database, :search_path_of
|
|
319
|
+
|
|
112
320
|
def lexicon_classes
|
|
113
321
|
classes = []
|
|
114
322
|
classes << LexiconOhdsi unless ENV["LEXICON_GDM_ONLY"]
|
data/lib/conceptql/nodifier.rb
CHANGED
|
@@ -20,7 +20,13 @@ module ConceptQL
|
|
|
20
20
|
(opts[:data_model] || cdb.opts[:data_model] || ENV["CONCEPTQL_DATA_MODEL"] || ConceptQL::DEFAULT_DATA_MODEL).to_sym
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# An empty statement ([], as a whole statement, an operand, or the stored
|
|
24
|
+
# statement of an algorithm) has no operator. It becomes an Invalid
|
|
25
|
+
# operator with the error "empty statement", so validation reports it.
|
|
26
|
+
def create(*statement)
|
|
27
|
+
return Operators::Invalid.new(self, "invalid", errors: [ [ "empty statement" ] ]) if statement.empty?
|
|
28
|
+
|
|
29
|
+
operator, *values = statement
|
|
24
30
|
operator = operator.to_s.downcase
|
|
25
31
|
if operator == "algorithm"
|
|
26
32
|
statement, = algorithm_fetcher.call(values.first)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
# Counts the rows and the distinct people of every operator in a statement
|
|
5
|
+
# with one SQL statement, for Operator#annotate. The SQL statement is the
|
|
6
|
+
# root query's CTEs plus a UNION ALL of one count for each operator's CTE.
|
|
7
|
+
#
|
|
8
|
+
# This needs MATERIALIZED CTEs, so that each CTE runs once. Spark does not
|
|
9
|
+
# honor MATERIALIZED, so on Spark each operator counts its rows with its own
|
|
10
|
+
# query (Operator#domain_counts). That per-operator path is also used when
|
|
11
|
+
# the statement has an operator that #annotate does not count (an invalid
|
|
12
|
+
# operator, or no database), with temp tables instead of CTEs, and with the
|
|
13
|
+
# Database's share_utilization option, whose tables are not CTEs.
|
|
14
|
+
#
|
|
15
|
+
# Nothing runs until the first operator asks for its counts.
|
|
16
|
+
class OneQueryCounts
|
|
17
|
+
OPERATOR_COLUMN = :conceptql_operator
|
|
18
|
+
|
|
19
|
+
def initialize(root, db, opts)
|
|
20
|
+
@root = root
|
|
21
|
+
@db = db
|
|
22
|
+
@opts = opts
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The counts of op as { criterion_domain => { rows:, n: } }, without the
|
|
26
|
+
# domains that have no rows. nil if this statement cannot be counted with
|
|
27
|
+
# one SQL statement.
|
|
28
|
+
def for(op)
|
|
29
|
+
counts&.fetch(op)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The SQL statement that counts every operator, or nil.
|
|
33
|
+
def dataset
|
|
34
|
+
return @dataset if defined?(@dataset)
|
|
35
|
+
|
|
36
|
+
@dataset = build_dataset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :root, :db, :opts
|
|
42
|
+
|
|
43
|
+
def scope
|
|
44
|
+
root.scope
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def counts
|
|
48
|
+
return @counts if defined?(@counts)
|
|
49
|
+
|
|
50
|
+
@counts = dataset && read_counts
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_counts
|
|
54
|
+
by_index = Array.new(@operators.length) { {} }
|
|
55
|
+
dataset.each do |h|
|
|
56
|
+
by_index[h[OPERATOR_COLUMN]][h[:criterion_domain].to_sym] = { rows: h[:rows], n: h[:n] }
|
|
57
|
+
end
|
|
58
|
+
@operators.zip(by_index).to_h.compare_by_identity
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_dataset
|
|
62
|
+
return unless root.rdbms.supports_materialized?
|
|
63
|
+
return if scope.force_temp_tables? || ConceptQL.avoid_ctes?
|
|
64
|
+
|
|
65
|
+
@operators = operators
|
|
66
|
+
return unless @operators.all? { |op| op.countable?(db, opts) }
|
|
67
|
+
|
|
68
|
+
query, relations = root_query
|
|
69
|
+
# A Union reads its Vocabulary upstreams with one combined query, so the
|
|
70
|
+
# root query has no CTE for them. Their own CTEs join the WITH list.
|
|
71
|
+
# The second root_query evaluates the tree again. Reusing the first
|
|
72
|
+
# evaluation would give the root CTEs other names (each evaluation
|
|
73
|
+
# takes new numbers from the scope), and the SQL would change.
|
|
74
|
+
unless relations.all?
|
|
75
|
+
extra = @operators.zip(relations).filter_map { |op, name| op.evaluate(db) unless name }
|
|
76
|
+
query, relations = root_query(extra)
|
|
77
|
+
return unless relations.all?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
query.from(relations.each_with_index.map { |name, i| count_query(name, i) }
|
|
81
|
+
.reduce { |all, one| all.union(one, all: true, from_self: false) })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The root query, and the name of the CTE in its WITH list that holds the
|
|
85
|
+
# rows of each operator (nil when it has none). An operator's CTE that
|
|
86
|
+
# Scope#merge_identical_ctes removed is read through the CTE that
|
|
87
|
+
# replaced it.
|
|
88
|
+
def root_query(extra = [])
|
|
89
|
+
query = scope.with_ctes(root, db, extra: extra)
|
|
90
|
+
with_names = Array(query.opts[:with]).map { |w| w[:name] }
|
|
91
|
+
merged = query.opts.fetch(Scope::MERGED_CTES, {})
|
|
92
|
+
relations = @operators.map do |op|
|
|
93
|
+
scope.cte_names(op).reverse_each.map { |name| merged.fetch(name, name) }.find { |name| with_names.include?(name) }
|
|
94
|
+
end
|
|
95
|
+
[ query, relations ]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every operator that #annotate visits: the statement's tree, and the
|
|
99
|
+
# labeled operators that Recall operators stand in for.
|
|
100
|
+
def operators
|
|
101
|
+
found = []
|
|
102
|
+
visit = lambda do |op|
|
|
103
|
+
next if op.nil? || found.any? { |known| known.equal?(op) }
|
|
104
|
+
|
|
105
|
+
found << op
|
|
106
|
+
op.all_upstreams.each(&visit)
|
|
107
|
+
end
|
|
108
|
+
visit.call(root)
|
|
109
|
+
scope.known_operators.each_value(&visit)
|
|
110
|
+
found
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def count_query(name, index)
|
|
114
|
+
db[name].select_group(:criterion_domain)
|
|
115
|
+
.select_append(Sequel.as(index, OPERATOR_COLUMN))
|
|
116
|
+
.select_append { Sequel.function(:count, 1).as(:rows) }
|
|
117
|
+
.select_append { count(:person_id).distinct.as(:n) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -8,6 +8,10 @@ module ConceptQL
|
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
10
|
desc "Counts the number of records that exactly match across all columns."
|
|
11
|
+
# Every stream is a set, so count returns 1 for nearly every row
|
|
12
|
+
# (seed conceptql-e8n, ruled 2026-10-30).
|
|
13
|
+
deprecated "count will become an error in a later release. To count the events of each person, " \
|
|
14
|
+
'use ["sum", ["numeric", 1, <stream>], {"group_by_person": true}].'
|
|
11
15
|
allows_one_upstream
|
|
12
16
|
validate_one_upstream
|
|
13
17
|
validate_no_arguments
|
|
@@ -7,7 +7,7 @@ module ConceptQL
|
|
|
7
7
|
class Dedup < PassThru
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "
|
|
10
|
+
desc "Passes along each distinct record once. Records that have the same value in every column become one record."
|
|
11
11
|
allows_one_upstream
|
|
12
12
|
validate_one_upstream
|
|
13
13
|
|
|
@@ -52,10 +52,13 @@ module ConceptQL
|
|
|
52
52
|
# Get allowed episode gap
|
|
53
53
|
gap_of = get_episode_gap
|
|
54
54
|
|
|
55
|
+
# episode_end_date is the latest end so far. The lag gives it for the
|
|
56
|
+
# records before this one, so a record starts a new episode when it
|
|
57
|
+
# starts more than gap_of days after the end of the records before it.
|
|
55
58
|
gap_check = date_adjust_add(db,
|
|
56
59
|
Sequel.function(:lag, :episode_end_date).over(partition: partition_vars, order: %i[start_date end_date]), gap_of, "days")
|
|
57
60
|
|
|
58
|
-
cond_gap_lt_start = (gap_check < Sequel[:
|
|
61
|
+
cond_gap_lt_start = (gap_check < Sequel[:start_date]) | (nil)
|
|
59
62
|
|
|
60
63
|
dt2 = dt1.from_self.select_append(
|
|
61
64
|
gap_of.as(:gap_of),
|
|
@@ -98,7 +101,12 @@ module ConceptQL
|
|
|
98
101
|
Sequel.function(:max, :episode_end_date).as(:end_date)
|
|
99
102
|
)
|
|
100
103
|
|
|
101
|
-
#
|
|
104
|
+
# An episode has no criterion_id, criterion_table or criterion_domain
|
|
105
|
+
# of its own, so these constants name it in every output row. They
|
|
106
|
+
# were added because a NULL part made the uuid NULL. The uuid now
|
|
107
|
+
# gives a NULL part as an empty string (bead conceptql-18n), but the
|
|
108
|
+
# constants stay, because they are part of each episode row: the
|
|
109
|
+
# expected results in test/results/gdm/episode/ show them.
|
|
102
110
|
episode_summary = episode_summary.from_self.select_append(
|
|
103
111
|
Sequel[0].cast_numeric.as(:criterion_id),
|
|
104
112
|
Sequel.cast_string("episode").as(:criterion_table),
|
|
@@ -10,6 +10,11 @@ module ConceptQL
|
|
|
10
10
|
desc "Removes all incoming records that appear in the right hand set of records."
|
|
11
11
|
default_query_columns
|
|
12
12
|
|
|
13
|
+
# An `as` option would cast both streams before the comparison. Except
|
|
14
|
+
# has no such option, and a statement that sets one fails validation
|
|
15
|
+
# instead of running as a plain except (bead conceptql-dlq).
|
|
16
|
+
unsupported_options :as
|
|
17
|
+
|
|
13
18
|
def query(db)
|
|
14
19
|
cols_to_compare = %i[
|
|
15
20
|
criterion_id
|
|
@@ -7,9 +7,14 @@ module ConceptQL
|
|
|
7
7
|
class Filter < BinaryOperatorOperator
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "Keeps left hand records that share the same person, criterion_id, and criterion_domain with a right hand side record."
|
|
10
|
+
desc "Keeps left hand records that share the same person, criterion_id, criterion_table, and criterion_domain with a right hand side record."
|
|
11
11
|
default_query_columns
|
|
12
12
|
|
|
13
|
+
# An `as` option would cast both streams before the comparison. Filter
|
|
14
|
+
# has no such option, and a statement that sets one fails validation
|
|
15
|
+
# instead of running as a plain filter (bead conceptql-8ac).
|
|
16
|
+
unsupported_options :as
|
|
17
|
+
|
|
13
18
|
def query(db)
|
|
14
19
|
rhs = right.evaluate(db)
|
|
15
20
|
rhs = rhs.from_self.select_group(*columns)
|
|
@@ -26,8 +31,13 @@ module ConceptQL
|
|
|
26
31
|
columns.map { |c| [ Sequel[:l][c], Sequel[:r][c] ] }
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
# criterion_id is an id within its criterion_table, so two records from
|
|
35
|
+
# different tables can share one. Without criterion_table, a condition
|
|
36
|
+
# from observations matched a hospitalization from collections (both
|
|
37
|
+
# have the condition_occurrence domain) whenever their ids were equal
|
|
38
|
+
# (bead conceptql-fun).
|
|
29
39
|
def determine_columns
|
|
30
|
-
columns = %w[person_id criterion_id criterion_domain]
|
|
40
|
+
columns = %w[person_id criterion_id criterion_table criterion_domain]
|
|
31
41
|
columns += %w[start_date end_date] unless options[:ignore_dates]
|
|
32
42
|
columns.map(&:to_sym)
|
|
33
43
|
end
|