conceptql 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +498 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +2091 -0
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -3
  10. data/lib/conceptql/cli.rb +1 -1
  11. data/lib/conceptql/data_model/base.rb +3 -171
  12. data/lib/conceptql/data_model/gdm.rb +3 -1
  13. data/lib/conceptql/data_model.rb +20 -4
  14. data/lib/conceptql/database.rb +9 -2
  15. data/lib/conceptql/date_adjuster.rb +12 -5
  16. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  17. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  18. data/lib/conceptql/lexicon/lexicon_strategy.rb +58 -4
  19. data/lib/conceptql/lexicon.rb +218 -2
  20. data/lib/conceptql/nodifier.rb +7 -1
  21. data/lib/conceptql/one_query_counts.rb +120 -0
  22. data/lib/conceptql/operators/co_reported.rb +4 -31
  23. data/lib/conceptql/operators/count.rb +4 -0
  24. data/lib/conceptql/operators/death.rb +1 -1
  25. data/lib/conceptql/operators/dedup.rb +1 -1
  26. data/lib/conceptql/operators/episode.rb +10 -2
  27. data/lib/conceptql/operators/ethnicity.rb +0 -4
  28. data/lib/conceptql/operators/except.rb +5 -0
  29. data/lib/conceptql/operators/filter.rb +12 -2
  30. data/lib/conceptql/operators/hospice.rb +2 -3
  31. data/lib/conceptql/operators/hospitalization.rb +2 -3
  32. data/lib/conceptql/operators/information_periods.rb +5 -4
  33. data/lib/conceptql/operators/invalid.rb +7 -1
  34. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  35. data/lib/conceptql/operators/numeric.rb +51 -14
  36. data/lib/conceptql/operators/occurrence.rb +16 -4
  37. data/lib/conceptql/operators/one_in_two_out.rb +1 -2
  38. data/lib/conceptql/operators/operator.rb +66 -30
  39. data/lib/conceptql/operators/person.rb +1 -1
  40. data/lib/conceptql/operators/person_filter.rb +5 -0
  41. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  42. data/lib/conceptql/operators/provenance.rb +13 -4
  43. data/lib/conceptql/operators/provider_filter.rb +5 -1
  44. data/lib/conceptql/operators/race.rb +2 -10
  45. data/lib/conceptql/operators/read.rb +1 -100
  46. data/lib/conceptql/operators/recall.rb +1 -1
  47. data/lib/conceptql/operators/snf.rb +3 -4
  48. data/lib/conceptql/operators/sum.rb +46 -5
  49. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  50. data/lib/conceptql/operators/trim_date.rb +52 -6
  51. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  52. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  53. data/lib/conceptql/operators/union.rb +45 -1
  54. data/lib/conceptql/operators/vocabulary.rb +21 -4
  55. data/lib/conceptql/paths.rb +16 -0
  56. data/lib/conceptql/query.rb +78 -24
  57. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  58. data/lib/conceptql/rdbms/generic.rb +21 -1
  59. data/lib/conceptql/rdbms/spark.rb +12 -0
  60. data/lib/conceptql/scope.rb +187 -24
  61. data/lib/conceptql/version.rb +1 -1
  62. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  63. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  64. data/lib/conceptql/vocabularies/entry.rb +15 -23
  65. data/lib/conceptql/vocabularies/formats.rb +59 -0
  66. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
  67. data/lib/conceptql/window/table.rb +12 -3
  68. metadata +9 -14
  69. data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
  70. data/lib/conceptql/operators/concurrent_within.rb +0 -69
  71. data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
  72. data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
  73. data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
  74. data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
  75. data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
  76. data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
  77. data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
  78. data/schemas/omopv4_plus.yml +0 -729
  79. data/schemas/omopv4_plus_more_ignorables.tsv +0 -13
@@ -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
@@ -8,9 +10,9 @@ module ConceptQL
8
10
  @db = db
9
11
  end
10
12
 
11
- # A lookup is always scoped to a vocabulary. `vocabulary_id` is polymorphic:
12
- # a String, an Integer (ReadOmop reports 17), or an Array of vocabulary names
13
- # (the provenance-type pairs), which Sequel turns into an IN clause.
13
+ # A lookup is always scoped to a vocabulary. `vocabulary_id` is a String or
14
+ # an Array of vocabulary names (the provenance-type pairs), which Sequel turns
15
+ # into an IN clause.
14
16
  #
15
17
  # There is deliberately no wildcard. `'*'` is meaningful elsewhere in the
16
18
  # operator family, but only in `arguments` — Vocabulary#select_all? reads it
@@ -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(Sequel.function(:lower, :concept_code) => Array(codes).map(&:downcase)) unless codes.blank?
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
 
@@ -98,6 +124,34 @@ module ConceptQL
98
124
  concepts(db, vocabulary_id, codes).select_map(%i[concept_code concept_text])
99
125
  end
100
126
 
127
+ # The distinct names of each code that has more than one name, as
128
+ # { downcased code => [name, ...] } with the names sorted. A vocabulary
129
+ # can reuse a code for a new meaning: DRG 410 meant chemotherapy until
130
+ # 2007-09-30 and a biliary tract procedure after that. OHDSI keeps each
131
+ # meaning as its own concept row, and the GDM vocabulary keeps both names.
132
+ # Names that differ only in case or spacing count as one name.
133
+ # A caller can answer "is the concept table missing?" with a block.
134
+ def code_meanings(data_db, vocabulary_id, codes)
135
+ return {} if codes.blank? || db.nil? || db_is_mock?(data_db)
136
+ return {} if block_given? ? yield : table_is_missing?(data_db)
137
+
138
+ concepts(db, vocabulary_id, codes)
139
+ .select_map(%i[concept_code concept_text])
140
+ .group_by { |code, _| code.downcase }
141
+ .transform_values { |rows| rows.map(&:last).compact.uniq { |name| name.downcase.split.join(" ") }.sort }
142
+ .select { |_, names| names.length > 1 }
143
+ rescue Sequel::DatabaseError
144
+ {}
145
+ end
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
+
101
155
  def vocabulary_is_empty?(data_db, vocabulary_id)
102
156
  concepts_table(data_db).where(vocabulary_id: vocabulary_id).count.zero?
103
157
  end
@@ -14,10 +14,12 @@ module ConceptQL
14
14
  # cache never outlives one export (seed conceptql-a5f). Methods that return
15
15
  # datasets are not cached: their query runs later, in the caller.
16
16
  CACHED_LOOKUPS = %i[
17
+ code_meanings
17
18
  concept_codes
18
19
  concept_ids
19
20
  concept_ids_by_name
20
21
  concepts_to_codes
22
+ concepts_to_codes_in_class
21
23
  descendants_of
22
24
  known_codes
23
25
  related_concept_ids
@@ -34,16 +36,158 @@ module ConceptQL
34
36
  :vocabularies,
35
37
  :vocabularies_query
36
38
 
39
+ # The key of each lexicon kind in NoVocabularyError::Search#missing_tables.
40
+ LEXICON_KINDS = { LexiconOhdsi => :ohdsi, LexiconGDM => :gdm }.freeze
41
+
37
42
  # The cached results keyed on [method, arguments], or nil when caching is
38
43
  # off. Its size is the number of distinct lookups.
39
44
  attr_reader :cache
40
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
+
41
185
  def initialize(lexicon_db, dataset_db = nil, strategy: nil, cache: false)
42
186
  @strategy = strategy || determine_strategy(lexicon_db, dataset_db)
43
187
  @cache = {} if cache
44
188
  end
45
189
 
46
- (CACHED_LOOKUPS - %i[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|
47
191
  define_method(name) do |*args|
48
192
  lookup(name, args) { @strategy.public_send(name, *args) }
49
193
  end
@@ -65,6 +209,20 @@ module ConceptQL
65
209
  end
66
210
  end
67
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
+
219
+ # Like concepts_to_codes, it asks about the concept table through the block.
220
+ def code_meanings(data_db, vocabulary_id, codes)
221
+ lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
222
+ @strategy.code_meanings(data_db, vocabulary_id, codes) { table_is_missing?(data_db) }
223
+ end
224
+ end
225
+
68
226
  # Only "the table is there" is kept. A missing table (or a database error,
69
227
  # which Sequel's table_exists? turns into "missing") is checked again on
70
228
  # the next call, so one transient error does not last the whole export.
@@ -90,17 +248,75 @@ module ConceptQL
90
248
  end
91
249
  private :lookup
92
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.
93
257
  def determine_strategy(lexicon_db, dataset_db)
94
- [ dataset_db, lexicon_db ].compact.each do |db|
258
+ [ (dataset_db if real_database?(dataset_db)), lexicon_db ].compact.each do |db|
95
259
  lexicon_classes.each do |klass|
96
260
  next unless klass.db_has_all_vocabulary_tables?(db)
97
261
 
98
262
  return (klass.new(db))
99
263
  end
100
264
  end
265
+ raise no_vocabulary_error(lexicon_db, dataset_db) if real_database?(dataset_db)
266
+
101
267
  LexiconNoDB.new(Sequel.mock(host: :postgres))
102
268
  end
103
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
+
104
320
  def lexicon_classes
105
321
  classes = []
106
322
  classes << LexiconOhdsi unless ENV["LEXICON_GDM_ONLY"]
@@ -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
- def create(operator, *values)
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
@@ -17,7 +17,10 @@ module ConceptQL
17
17
  vocabularies.length > 1
18
18
  end
19
19
 
20
- desc "Passes along all events that were co-reported in the same record in the source data."
20
+ desc "Passes along the events of its upstream streams that were reported in the same source record " \
21
+ "(the same context). A record is kept only if every upstream stream " \
22
+ "has at least one event in it. If any one upstream has no event in a record, the events of all " \
23
+ "streams in that record are dropped, without a warning."
21
24
 
22
25
  allows_many_upstreams
23
26
  category "Combine Streams"
@@ -27,14 +30,6 @@ module ConceptQL
27
30
  require_column :context_id
28
31
 
29
32
  def query(db)
30
- if gdm?
31
- gdm(db)
32
- else
33
- events_with_common_visits(db).inject { |q, query| q.union(query, all: true) }
34
- end
35
- end
36
-
37
- def gdm(db)
38
33
  contexteds = upstreams.map do |stream|
39
34
  [ stream.cte_name(:contextified), contextify(db, stream) ]
40
35
  end.to_h
@@ -84,28 +79,6 @@ module ConceptQL
84
79
  # .select_append(Sequel[:c][:context_id].as(:context_id))
85
80
  # .from_self
86
81
  end
87
-
88
- private
89
-
90
- def visit_occurrence_ids_in_common(db)
91
- @visit_occurrence_ids_in_common ||= upstream_queries(db).map do |q|
92
- q.select(:visit_occurrence_id)
93
- end.inject do |q, query|
94
- q.from_self(alias: :tab1)
95
- .join(query.as(:tab2), visit_occurrence_id: :visit_occurrence_id)
96
- .select(Sequel[:tab1][:visit_occurrence_id].as(:visit_occurrence_id))
97
- end
98
- end
99
-
100
- def events_with_common_visits(db)
101
- upstream_queries(db).map { |q| q.where(visit_occurrence_id: visit_occurrence_ids_in_common(db)).from_self }
102
- end
103
-
104
- def upstream_queries(db)
105
- @upstream_queries ||= upstreams.map do |expression|
106
- expression.evaluate(db).select(*query_cols).from_self
107
- end
108
- end
109
82
  end
110
83
  end
111
84
  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
@@ -9,7 +9,7 @@ module ConceptQL
9
9
 
10
10
  register __FILE__
11
11
 
12
- desc "Selects all death records"
12
+ desc "Selects all death records. With an upstream stream, the operator selects the death record of each person in that stream."
13
13
  domains :death
14
14
  allows_one_upstream
15
15
 
@@ -7,7 +7,7 @@ module ConceptQL
7
7
  class Dedup < PassThru
8
8
  register __FILE__
9
9
 
10
- desc "Counts the number of records that exactly match across all columns."
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[:episode_end_date]) | (nil)
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
- # Episodes streams return null for criterion_id,table, and domain which messes up uuid generation so we had to add constants to these values
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),
@@ -15,10 +15,6 @@ module ConceptQL
15
15
  validate_no_upstreams
16
16
  validate_at_least_one_argument
17
17
 
18
- def query_cols
19
- table_columns(table)
20
- end
21
-
22
18
  def table
23
19
  source_table
24
20
  end
@@ -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