conceptql 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +454 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +207 -67
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -1
  10. data/lib/conceptql/data_model/base.rb +2 -4
  11. data/lib/conceptql/data_model/gdm.rb +3 -1
  12. data/lib/conceptql/database.rb +7 -2
  13. data/lib/conceptql/date_adjuster.rb +12 -5
  14. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  15. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  16. data/lib/conceptql/lexicon/lexicon_strategy.rb +35 -1
  17. data/lib/conceptql/lexicon.rb +210 -2
  18. data/lib/conceptql/nodifier.rb +7 -1
  19. data/lib/conceptql/one_query_counts.rb +120 -0
  20. data/lib/conceptql/operators/count.rb +4 -0
  21. data/lib/conceptql/operators/death.rb +1 -1
  22. data/lib/conceptql/operators/dedup.rb +1 -1
  23. data/lib/conceptql/operators/episode.rb +10 -2
  24. data/lib/conceptql/operators/ethnicity.rb +0 -4
  25. data/lib/conceptql/operators/except.rb +5 -0
  26. data/lib/conceptql/operators/filter.rb +12 -2
  27. data/lib/conceptql/operators/hospice.rb +1 -1
  28. data/lib/conceptql/operators/hospitalization.rb +1 -1
  29. data/lib/conceptql/operators/information_periods.rb +4 -1
  30. data/lib/conceptql/operators/invalid.rb +7 -1
  31. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  32. data/lib/conceptql/operators/numeric.rb +51 -14
  33. data/lib/conceptql/operators/occurrence.rb +16 -4
  34. data/lib/conceptql/operators/operator.rb +63 -19
  35. data/lib/conceptql/operators/person.rb +1 -1
  36. data/lib/conceptql/operators/person_filter.rb +5 -0
  37. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  38. data/lib/conceptql/operators/provenance.rb +12 -2
  39. data/lib/conceptql/operators/provider_filter.rb +5 -1
  40. data/lib/conceptql/operators/race.rb +0 -4
  41. data/lib/conceptql/operators/recall.rb +1 -1
  42. data/lib/conceptql/operators/snf.rb +2 -2
  43. data/lib/conceptql/operators/sum.rb +46 -5
  44. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  45. data/lib/conceptql/operators/trim_date.rb +52 -6
  46. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  47. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  48. data/lib/conceptql/operators/union.rb +45 -1
  49. data/lib/conceptql/operators/vocabulary.rb +7 -3
  50. data/lib/conceptql/paths.rb +10 -0
  51. data/lib/conceptql/query.rb +78 -24
  52. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  53. data/lib/conceptql/rdbms/generic.rb +21 -1
  54. data/lib/conceptql/rdbms/spark.rb +12 -0
  55. data/lib/conceptql/scope.rb +187 -24
  56. data/lib/conceptql/version.rb +1 -1
  57. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  58. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  59. data/lib/conceptql/vocabularies/entry.rb +15 -3
  60. data/lib/conceptql/vocabularies/formats.rb +59 -0
  61. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
  62. data/lib/conceptql/window/table.rb +12 -3
  63. metadata +8 -4
  64. data/lib/conceptql/operators/concurrent_within.rb +0 -69
@@ -8,7 +8,7 @@ module ConceptQL
8
8
  register __FILE__
9
9
  include ConceptQL::Behaviors::Utilizable
10
10
 
11
- desc "Selects admission records of type 'inpatient'"
11
+ desc "Selects inpatient admission records."
12
12
 
13
13
  def collection_type
14
14
  "inpatient"
@@ -10,7 +10,10 @@ module ConceptQL
10
10
  include ConceptQL::Behaviors::Windowable
11
11
  include ConceptQL::Behaviors::Timeless
12
12
 
13
- desc "Generates all observation_period records, or, if fed a stream, fetches all observation_period records for the people represented in the incoming record set."
13
+ desc "Selects all information period records, which have the observation_period domain. " \
14
+ "The operator takes no upstream stream. " \
15
+ "Each argument is an information type concept ID, and with arguments, " \
16
+ "the operator selects only the information periods of those types."
14
17
  domains :observation_period
15
18
  category "Get Related Data"
16
19
  basic_type :selection
@@ -8,13 +8,19 @@ module ConceptQL
8
8
  no_desc
9
9
  default_query_columns
10
10
 
11
+ # The statement [nil] or [""] has no operator name. It is named
12
+ # "invalid", as an empty statement is, so it does not report its error
13
+ # under the key "" (bead conceptql-1ci).
11
14
  def initialize(*args)
12
15
  super
16
+ @op_name = "invalid" if op_name.to_s.empty?
13
17
  @errors = @options.delete(:errors)
14
18
  end
15
19
 
20
+ # The name in the statement. Operator#operator_name would give the class
21
+ # name, "invalid", for every unknown operator.
16
22
  def operator_name
17
- @operator_name ||= arguments.shift.to_s || "invalid"
23
+ op_name
18
24
  end
19
25
 
20
26
  def query(_db)
@@ -59,11 +59,25 @@ module ConceptQL
59
59
  # sharing a common table and feed them into a single single query,
60
60
  # but I think this would require some revamping of Vocabulary, and I'm
61
61
  # just not interested in taking that on right now.
62
+ all = disjoint_vocabularies?
62
63
  vocab_ops.map { |vo| vo.evaluate(db) }.inject do |union, q|
63
- union.union(q.from_self)
64
+ union.union(q.from_self, all: all)
64
65
  end
65
66
  end
66
67
 
68
+ # Each vocabulary operator selects only the rows whose vocabulary column
69
+ # holds its own vocabulary. A row holds one vocabulary, and each output
70
+ # row carries its source row's id as criterion_id, so operators with
71
+ # different vocabularies on one column never produce the same row. Then
72
+ # UNION ALL skips a duplicate removal that is expensive on large data
73
+ # (conceptql-5pz). A repeated row inside one branch now passes through,
74
+ # as it does for a single vocabulary operator and for union. A custom
75
+ # file can list one vocabulary twice, so those branches keep UNION.
76
+ def disjoint_vocabularies?
77
+ conditions = vocab_ops.map(&:vocabulary_condition)
78
+ conditions.map(&:keys).uniq.length == 1 && conditions.uniq.length == conditions.length
79
+ end
80
+
67
81
  def domains(_db)
68
82
  vocab_ops.map(&:domain).uniq
69
83
  end
@@ -92,10 +106,9 @@ module ConceptQL
92
106
  end
93
107
 
94
108
  # The per-code warnings a Vocabulary operator gives, from most to least
95
- # specific. See #code_warnings.
96
- BAD_FORMAT = "improperly formatted code"
97
- UNKNOWN_CODE = "unknown code(s)"
98
- CODE_WARNINGS = [ BAD_FORMAT, UNKNOWN_CODE ].freeze
109
+ # specific (Operator::BAD_FORMATS, Operator::UNKNOWN_CODE). See
110
+ # #code_warnings.
111
+ CODE_WARNINGS = [ *BAD_FORMATS, UNKNOWN_CODE ].freeze
99
112
 
100
113
  def additional_validation(db, opts = {})
101
114
  vocab_ops.each { |vo| vo.valid?(db, opts) }
@@ -118,7 +131,7 @@ module ConceptQL
118
131
  end
119
132
 
120
133
  rejected = arguments.uniq.select { |code| verdicts.all? { |verdict| verdict.key?(code) } }
121
- bad_format, unknown = rejected.partition { |code| verdicts.all? { |verdict| verdict[code] == BAD_FORMAT } }
134
+ bad_format, unknown = rejected.partition { |code| verdicts.all? { |verdict| BAD_FORMATS.include?(verdict[code]) } }
122
135
 
123
136
  [ [ BAD_FORMAT, *bad_format ], [ UNKNOWN_CODE, *unknown ] ].reject { |warning| warning.length == 1 }
124
137
  end
@@ -16,17 +16,11 @@ module ConceptQL
16
16
  class Numeric < PassThru
17
17
  register __FILE__
18
18
 
19
- desc <<~EOF
20
- Represents an operator that will either:
21
- - Create a value_as_number value for every person in the database
22
- - Change the value_as_number value for every record passed in
23
- - Either to a number
24
- - Or a value from a column in the origin row
25
-
26
- Accepts two params:
27
- - Either a number value or a symbol representing a column name
28
- - An optional stream
29
- EOF
19
+ desc "Sets the value_as_number of each record of its upstream stream. " \
20
+ "The argument is a number, or the name of a column whose value is copied from the same record. " \
21
+ "Without an upstream stream, the operator selects one person record for each person, " \
22
+ "with value_as_number set to the argument. " \
23
+ "A column name must then name a column of the person table."
30
24
  argument :value, type: :float
31
25
  allows_one_upstream
32
26
  validate_at_most_one_upstream
@@ -54,6 +48,39 @@ module ConceptQL
54
48
 
55
49
  private
56
50
 
51
+ # A column name in the argument must be a column of the rows it is read
52
+ # from, or the statement fails at run time. The match ignores case,
53
+ # because DuckDB and Spark resolve an identifier without regard to case,
54
+ # and such a statement runs on them today.
55
+ def additional_validation(_db, _opts = {})
56
+ name = column_argument
57
+ return if name.nil? || matched_column
58
+
59
+ add_error("unknown column", name)
60
+ end
61
+
62
+ # The column that the argument names, spelled as the rows spell it, or
63
+ # nil when the argument names no readable column. The SQL quotes the
64
+ # name, and Postgres folds only unquoted names, so the SQL must use this
65
+ # spelling for a match in another case to run there too.
66
+ def matched_column
67
+ name = column_argument
68
+ name && readable_columns.find { |column| column.to_s.casecmp?(name) }
69
+ end
70
+
71
+ # With an upstream, a column name is read from the upstream's rows
72
+ # (SELECT ... FROM (<upstream>)), not from the tables the upstream
73
+ # reads. Every stream emits the same columns, the scope's query columns,
74
+ # so one check covers an upstream that mixes tables, such as a union of
75
+ # two domains. Without an upstream, the name is read from the person
76
+ # table that as_criterion selects from, so it must be one of that
77
+ # table's columns in the data model's schema.
78
+ def readable_columns
79
+ return scope.query_columns if stream
80
+
81
+ dm.table_cols(dm.table_by_domain(:person))
82
+ end
83
+
57
84
  def with_kids(db)
58
85
  dm.selectify(db.from(stream.evaluate(db)), replace: { value_as_number: first_argument })
59
86
  end
@@ -79,15 +106,25 @@ module ConceptQL
79
106
  end
80
107
 
81
108
  def first_argument
82
- value = arguments.first.to_s.strip
83
- value = nil if value.blank?
109
+ value = argument_text
84
110
  if !value || is_a_number?(value)
85
111
  Sequel.cast_numeric(value, Float)
86
112
  else
87
- Sequel.identifier(value)
113
+ Sequel.identifier(matched_column || value)
88
114
  end
89
115
  end
90
116
 
117
+ def argument_text
118
+ value = arguments.first.to_s.strip
119
+ value.blank? ? nil : value
120
+ end
121
+
122
+ # The argument when it names a column, or nil when it is a number or blank
123
+ def column_argument
124
+ value = argument_text
125
+ value if value && !is_a_number?(value)
126
+ end
127
+
91
128
  def is_a_number?(value)
92
129
  value =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/
93
130
  end
@@ -33,7 +33,9 @@ module ConceptQL
33
33
 
34
34
  preferred_name "Nth Occurrence"
35
35
 
36
- desc "Groups all records by person, then orders by start_date and finds the nth occurrence. Can be positive or negative, e.g 2 means 'second' and -3 means 'third from last'."
36
+ desc "For each person, orders the records by start_date and passes along the record at the given position. " \
37
+ "A positive number counts from the earliest record, so 2 means the second record. " \
38
+ "A negative number counts from the most recent record, so -3 means the third record from the last."
37
39
 
38
40
  argument :occurrence, type: :integer
39
41
  category "Filter Single Stream"
@@ -73,7 +75,10 @@ module ConceptQL
73
75
  additional_order_columns = []
74
76
 
75
77
  if grouped?
76
- one_date_ds = ds.from_self.where(rn: 1).from_self.select_remove(:rn)
78
+ # Name the columns to keep. select_remove(:rn) asked the database
79
+ # for them, and the CTE of a labeled upstream does not exist while
80
+ # the SQL is built (bead conceptql-6k8).
81
+ one_date_ds = ds.from_self.where(rn: 1).from_self.select(*dynamic_columns)
77
82
  ds = one_date_ds.select_append(
78
83
  Sequel[:ROW_NUMBER].function.over(
79
84
  partition: matching_columns_plus,
@@ -114,10 +119,17 @@ module ConceptQL
114
119
  ).as(:timed_rn)
115
120
  )
116
121
 
117
- db[joined_name]
122
+ # Each first row nominates its nth rest row. With at_least, several
123
+ # first rows can nominate the same rest row: all rest rows that start
124
+ # after the same gap share one ranking. The join gives one row per
125
+ # nomination, so take the nominated rows from the input instead, and
126
+ # each comes out once (every stream is a set, seed conceptql-0eb).
127
+ nth_ds = db[joined_name].where(timed_rn: occurrence.abs)
128
+ keys = matching_columns_plus(:global_rn).map { |c| Sequel[:l][c] =~ Sequel[:r][c] }
129
+
130
+ rdbms.semi_join(db[input_name], nth_ds, *keys)
118
131
  .with(input_name, input_ds)
119
132
  .with(joined_name, joined_ds)
120
- .where(timed_rn: occurrence.abs)
121
133
  end
122
134
 
123
135
  def grouped?
@@ -33,6 +33,22 @@ module ConceptQL
33
33
 
34
34
  attr_reader :nodifier, :values, :options, :arguments, :upstreams, :op_name, :errors, :warnings
35
35
 
36
+ # The per-code warnings of a Vocabulary operator. A code that does not
37
+ # match its vocabulary's format is a warning, and the statement stays
38
+ # valid (Ryan ruled 2026-10-30, seeds conceptql-0eb and conceptql-ysx).
39
+ # The malformed code matches no rows. The formats come from
40
+ # vocabulary_formats (Vocabularies::Formats); a vocabulary without one
41
+ # is not checked. A well-formed code that the vocabulary does not know
42
+ # is also a warning.
43
+ BAD_FORMAT = "improperly formatted code"
44
+ # Claims carry the 11-digit package NDC (jde found no 9-digit product
45
+ # code in 601.9M claim NDCs, seed conceptql-ysx), so the NDC warning
46
+ # says why the code matches nothing. It starts with BAD_FORMAT, which
47
+ # the JDE algorithm spec looks for (bead conceptql-28h).
48
+ NDC_BAD_FORMAT = "#{BAD_FORMAT} (claims use 11-digit package NDCs, so it matches no rows)"
49
+ BAD_FORMATS = [ BAD_FORMAT, NDC_BAD_FORMAT ].freeze
50
+ UNKNOWN_CODE = "unknown code(s)"
51
+
36
52
  option :label, type: :string
37
53
 
38
54
  @validations = []
@@ -48,12 +64,6 @@ module ConceptQL
48
64
  end
49
65
  end
50
66
 
51
- def query_columns(*tables)
52
- define_method(:query_cols) do
53
- dm.table_columns(*tables)
54
- end
55
- end
56
-
57
67
  def default_query_columns
58
68
  define_method(:query_cols) do
59
69
  dynamic_columns
@@ -86,9 +96,9 @@ module ConceptQL
86
96
  end
87
97
  end
88
98
 
89
- # Declares inherited options that this operator does not support.
90
- # They leave the operator's metadata, and a statement that sets one
91
- # fails validation.
99
+ # Declares options that this operator does not support, inherited or
100
+ # not. An inherited one leaves the operator's metadata, and a
101
+ # statement that sets any of them fails validation.
92
102
  def unsupported_options(*names)
93
103
  names.each { |name| remove_option(name) }
94
104
  validations << [ :validate_unsupported_options, *names ]
@@ -170,15 +180,8 @@ module ConceptQL
170
180
  end
171
181
  res = [ op_name, *annotate_values(db, opts) ]
172
182
 
173
- if upstreams_valid?(db, opts) && scope.valid? && include_counts?(db, opts)
174
- scope.with_ctes(self, db)
175
- .from_self
176
- .select_group(:criterion_domain)
177
- .select_append { Sequel.function(:count, 1).as(:rows) }
178
- .select_append { count(:person_id).distinct.as(:n) }
179
- .each do |h|
180
- counts[h.delete(:criterion_domain).to_sym] = h
181
- end
183
+ if countable?(db, opts)
184
+ counts.merge!(scope.one_query_counts&.for(self) || domain_counts(db))
182
185
  elsif !errors.empty?
183
186
  annotation[:errors] = errors
184
187
  scope.add_errors(scope_key, errors)
@@ -188,6 +191,9 @@ module ConceptQL
188
191
  cur_counts = counts[domain] ||= { rows: 0, n: 0 }
189
192
  scope.add_counts(scope_key, domain, cur_counts)
190
193
  end
194
+ # The database returns its groups in no fixed order, and the zero
195
+ # counts above come last. Sort them, so each run gives the same order.
196
+ annotation[:counts] = counts.sort.to_h
191
197
 
192
198
  if defined?(@warnings) && !warnings.empty?
193
199
  annotation[:warnings] = warnings
@@ -211,9 +217,15 @@ module ConceptQL
211
217
  "<##{self.class} upstreams=[#{upstreams.map(&:inspect).join(', ')}] arguments=[#{arguments.map(&:inspect).join(', ')}]>"
212
218
  end
213
219
 
220
+ # The operator's rows, read from a CTE that Scope#with_ctes moves to the
221
+ # statement's WITH list. With CONCEPTQL_AVOID_CTES, the rows are a
222
+ # subquery instead, because with_ctes then makes no WITH list.
214
223
  def evaluate(db)
215
224
  q = select_it(query(db))
225
+ return q.from_self if ConceptQL.avoid_ctes?
226
+
216
227
  name = cte_name(op_name)
228
+ scope.record_cte(self, name)
217
229
  opts = {}
218
230
  opts[:materialized] = true if rdbms.supports_materialized?
219
231
  db[name].with(name, q, opts)
@@ -322,6 +334,26 @@ module ConceptQL
322
334
  valid?(db, opts) && upstreams.all? { |u| u.upstreams_valid?(db, opts) }
323
335
  end
324
336
 
337
+ # Whether #annotate counts this operator's rows and people.
338
+ def countable?(db, opts = {})
339
+ upstreams_valid?(db, opts) && scope.valid? && include_counts?(db, opts)
340
+ end
341
+
342
+ # Rows and distinct people of each criterion_domain in this operator's
343
+ # output, with a query for this operator alone.
344
+ def domain_counts(db)
345
+ counts = {}
346
+ scope.with_ctes(self, db)
347
+ .from_self
348
+ .select_group(:criterion_domain)
349
+ .select_append { Sequel.function(:count, 1).as(:rows) }
350
+ .select_append { count(:person_id).distinct.as(:n) }
351
+ .each do |h|
352
+ counts[h.delete(:criterion_domain).to_sym] = h
353
+ end
354
+ counts
355
+ end
356
+
325
357
  def scope
326
358
  nodifier.scope
327
359
  end
@@ -503,6 +535,7 @@ module ConceptQL
503
535
  @errors = [] unless defined?(@errors)
504
536
  @warnings = [] unless defined?(@warnings)
505
537
 
538
+ add_warning("deprecated operator", self.class.deprecated) if self.class.deprecated
506
539
  add_error("invalid label") if label && !label.is_a?(String)
507
540
  self.class.validations.each do |args|
508
541
  send(*args)
@@ -588,7 +621,12 @@ module ConceptQL
588
621
  def validate_codes_match
589
622
  return if bad_arguments.empty?
590
623
 
591
- add_warning("improperly formatted code", *bad_arguments)
624
+ add_warning(bad_format_message, *bad_arguments)
625
+ end
626
+
627
+ # The text of the format warning. Vocabulary gives the NDC its reason.
628
+ def bad_format_message
629
+ BAD_FORMAT
592
630
  end
593
631
 
594
632
  def additional_validation(_db, _opts = {})
@@ -634,8 +672,14 @@ module ConceptQL
634
672
  table = Sequel[table] if table.is_a?(Symbol)
635
673
  expr = exprs.inject(&:&)
636
674
  if use_inner_join?
675
+ # The join gives one copy of a left row for each matching right row.
676
+ # Every stream is a set (seed conceptql-0eb), so remove the copies.
677
+ # A caller can append right columns to the select list (temporal
678
+ # include_rhs_columns), and DISTINCT covers them: rows that differ
679
+ # by a right column stay (bead conceptql-3pr).
637
680
  ds.join(table.as(:r), expr)
638
681
  .select(*query_cols.map { |c| Sequel[:l][c] })
682
+ .distinct
639
683
  else
640
684
  rdbms.semi_join(ds, table, *exprs)
641
685
  end
@@ -10,7 +10,7 @@ module ConceptQL
10
10
 
11
11
  register __FILE__
12
12
 
13
- desc "Selects all records in the person table."
13
+ desc "Selects all person records. With an upstream stream, the operator selects the person record of each person in that stream."
14
14
 
15
15
  allows_one_upstream
16
16
  domains :person
@@ -10,6 +10,11 @@ module ConceptQL
10
10
  desc "Passes along left hand records with a corresponding person_id in the right hand records."
11
11
  default_query_columns
12
12
 
13
+ # PersonFilter has no `as` option, and a statement that sets one fails
14
+ # validation instead of running as a plain person_filter (bead
15
+ # conceptql-dlq).
16
+ unsupported_options :as
17
+
13
18
  def query(db)
14
19
  db.from(left.evaluate(db))
15
20
  .where(person_id: right.evaluate(db).from_self.select_group(:person_id))
@@ -4,22 +4,37 @@ require_relative "operator"
4
4
 
5
5
  module ConceptQL
6
6
  module Operators
7
- # Filters the incoming stream of records to only those that have a
8
- # an associated visit_occurrence with the matching place_of_service
7
+ # Filters the incoming stream of records to only those whose place of
8
+ # service is one of the given codes of the CMS Place of Service
9
+ # vocabulary.
9
10
  #
10
- # Provenance related concepts are the ones found in the xxx_type_concept_id
11
- # field.
11
+ # A record's place of service is pos_concept_id: a column of the record's
12
+ # context in gdm and of observations in gdm_wide. The PoS query modifiers
13
+ # bring it into the stream as visit_source_concept_id.
12
14
  #
13
- # If the record has NULL for the provenance-related field, they are filtered
14
- # out.
15
+ # If the record has NULL for its place of service, it is filtered out.
15
16
  #
16
- # Multiple provenances can be specified at once
17
+ # Multiple places of service can be specified at once. Each code becomes
18
+ # a concept ID through the lexicon, so a code that names no concept can
19
+ # match no record. Such a code is an error, as an unrecognized
20
+ # provenance keyword is.
21
+ #
22
+ # The code "*" keeps a record whose place of service is any concept of
23
+ # the vocabulary, as provenance's "*" keeps any provenance concept. A
24
+ # pos_concept_id of another vocabulary is not a place of service. Because
25
+ # codes are ORed, "*" with other codes is "*". The other codes are still
26
+ # checked.
17
27
  class PlaceOfServiceFilter < Operator
18
28
  register __FILE__
19
29
 
20
- desc "Passes along records that match one or more of the Medicare Place Of Service values."
30
+ VOCABULARY_ID = "CMS Place of Service"
31
+ ANY = "*"
32
+
33
+ desc "Passes along the records whose place of service is one of the given CMS Place of Service codes. " \
34
+ "The code * matches a record that has any place of service. " \
35
+ "A record that has no place of service is not passed along."
21
36
 
22
- argument :places_of_service, type: :codelist, vocab: "Place of Service"
37
+ argument :places_of_service, type: :codelist, vocab: VOCABULARY_ID
23
38
  category "Filter Single Stream"
24
39
  basic_type :temporal
25
40
  allows_one_upstream
@@ -35,8 +50,21 @@ module ConceptQL
35
50
 
36
51
  private
37
52
 
53
+ # With no codes, the lookup returns every concept of the vocabulary.
38
54
  def place_of_service_concept_ids(db)
39
- dm.concept_ids(db, "Place of Service", arguments.map(&:to_s))
55
+ codes = arguments.map(&:to_s)
56
+ dm.concept_ids(db, VOCABULARY_ID, codes.include?(ANY) ? [] : codes)
57
+ end
58
+
59
+ # With no lexicon there are no codes to check against, and every code
60
+ # would be reported. Provenance skips its check in the same situation.
61
+ # A code is known only in its own case, as the lookup finds it.
62
+ def additional_validation(db, _opts = {})
63
+ return if dm.lexicon.strategy == :no_db
64
+
65
+ known = dm.concept_codes(db, VOCABULARY_ID)
66
+ unknown = arguments.reject { |code| code.to_s == ANY || known.include?(code.to_s) }
67
+ add_error(UNKNOWN_CODE, *unknown) unless unknown.empty?
40
68
  end
41
69
  end
42
70
  end
@@ -14,11 +14,19 @@ module ConceptQL
14
14
  # If the record has NULL for the provenance-related field, they are filtered
15
15
  # out.
16
16
  #
17
- # Multiple provenances can be specified at once
17
+ # Multiple provenances can be specified at once. "*" asks for any
18
+ # provenance. At least one keyword is required.
18
19
  class Provenance < Operator
19
20
  register __FILE__
20
21
 
21
- desc "Passes along records with the indicated provenance (e.g. inpatient, outpatient, file type)."
22
+ desc "Passes along the records whose provenance matches at least one of the given keywords. " \
23
+ "Each record has a file provenance, such as inpatient or outpatient, " \
24
+ "and a code provenance, such as primary or first. " \
25
+ "A plain keyword matches a record when either provenance matches it. " \
26
+ "A keyword of the form <file keyword>:<code keyword> matches a record when its file provenance " \
27
+ "matches the file keyword and its code provenance matches the code keyword. " \
28
+ "The keyword * matches a record that has any file or code provenance. " \
29
+ "At least one keyword is required."
22
30
 
23
31
  argument :provenance_types, label: "Provenance Types", type: :codelist
24
32
  category "Filter Single Stream"
@@ -39,6 +47,8 @@ module ConceptQL
39
47
  private
40
48
 
41
49
  def additional_validation(db, _opts = {})
50
+ return add_error('no provenance keywords; use "*" for any provenance') if arguments.empty?
51
+
42
52
  # With no lexicon there are no provenance concepts to check against,
43
53
  # and every keyword would be reported as unrecognized. Vocabulary
44
54
  # operators skip their unknown-code check in the same situation.
@@ -9,7 +9,11 @@ module ConceptQL
9
9
  class ProviderFilter < Operator
10
10
  register __FILE__
11
11
 
12
- desc "Passes along records where the provider's specialty matches a given set of specialty_concept_ids.\n\nA single record may have multiple specialties, so the operator will return all records that match any of the given specialties, which may lead to duplicate records."
12
+ desc "Passes along the records whose provider matches the given specialties and roles. " \
13
+ "Each option is a comma-separated list of concept IDs, and * matches any value. " \
14
+ "A record matches the specialties when its context specialty or its practitioner specialty " \
15
+ "is in the list. " \
16
+ "A record can have more than one specialty, so the operator can pass along the same record more than once."
13
17
  option :specialties, type: :string
14
18
  option :roles, type: :string
15
19
  category "Filter Single Stream"
@@ -29,10 +29,6 @@ module ConceptQL
29
29
  @race_descendents ||= YAML.load_file(ConceptQL.race_file)
30
30
  end
31
31
 
32
- def query_cols
33
- table_columns(table)
34
- end
35
-
36
32
  def table
37
33
  source_table
38
34
  end
@@ -14,7 +14,7 @@ module ConceptQL
14
14
  class Recall < Operator
15
15
  register __FILE__
16
16
 
17
- desc "Recalls a set of records from a labeled operator in the statement"
17
+ desc "Passes along the records of the operator in the statement that has the given label."
18
18
 
19
19
  argument :name, type: :string
20
20
  category "Get Related Data"
@@ -8,10 +8,10 @@ module ConceptQL
8
8
  register __FILE__
9
9
  include ConceptQL::Behaviors::Utilizable
10
10
 
11
- desc "Selects admission records of type 'SNF'"
11
+ desc "Selects skilled nursing facility (SNF) admission records."
12
12
 
13
13
  def collection_type
14
- "SNF"
14
+ "snf"
15
15
  end
16
16
  end
17
17
  end
@@ -8,7 +8,12 @@ module ConceptQL
8
8
  register __FILE__
9
9
 
10
10
  def events_per_patient
11
- :multiple
11
+ group_by_person? ? :single : :multiple
12
+ end
13
+
14
+ # A person result per person, as the person cast gives (seed conceptql-6c3)
15
+ def domains(db)
16
+ group_by_person? ? [ :person ] : super
12
17
  end
13
18
 
14
19
  def multiple_vocabularies
@@ -17,16 +22,23 @@ module ConceptQL
17
22
  vocabularies.length > 1
18
23
  end
19
24
 
20
- desc <<~EOF
21
- Sums value_as_number across all records that match on all but start_date, end_date.
22
- For start_date and end_date the min and max of each respectively is returned.'
23
- EOF
25
+ desc "Groups the records that have the same values in all columns except criterion_id, start_date, " \
26
+ "end_date and value_as_number. For each group, the operator passes along one record " \
27
+ "with the sum of value_as_number, the earliest start_date, the most recent end_date " \
28
+ "and a criterion_id of 0. With group_by_person, the operator groups all of the records " \
29
+ "of each person and passes along one person record for each person."
30
+ option :group_by_person, type: :boolean,
31
+ instructions: "Choose whether to sum all of the records of each person into one person record, for example to count the events of each person"
24
32
  default_query_columns
25
33
  validate_at_least_one_upstream
26
34
  validate_no_arguments
35
+ # The editor's checkbox sends true or false, and an unset field nil
36
+ validate_option ->(value) { [ true, false, nil ].include?(value) }, :group_by_person
27
37
  require_column :value_as_number
28
38
 
29
39
  def query(db)
40
+ return per_person_query(db) if group_by_person?
41
+
30
42
  db.from(unioned(db))
31
43
  .select_group(*(dynamic_columns - %i[start_date end_date criterion_id value_as_number]))
32
44
  .select_append(Sequel.lit("?", 0).as(:criterion_id))
@@ -36,6 +48,35 @@ module ConceptQL
36
48
  .from_self
37
49
  end
38
50
 
51
+ # One row for each person (and window): the earliest start_date, the
52
+ # latest end_date and the sum of value_as_number. criterion_table,
53
+ # criterion_domain and column_family are those of the person cast. No
54
+ # single record stands behind the row, so criterion_id is 0 and the
55
+ # other columns are NULL.
56
+ def per_person_query(db)
57
+ person = {
58
+ criterion_id: Sequel.lit("?", 0),
59
+ criterion_table: Sequel.cast_string(dm.table_by_domain(:person).to_s),
60
+ criterion_domain: Sequel.cast_string("person"),
61
+ column_family: Sequel.cast_string(ConceptQL::Scope::DEFAULT_COLUMN_FAMILY)
62
+ }
63
+ aggregated = %i[start_date end_date value_as_number]
64
+ nulls = dynamic_columns - matching_columns - person.keys - aggregated
65
+
66
+ db.from(unioned(db))
67
+ .select_group(*matching_columns)
68
+ .select_append(*person.map { |column, value| value.as(column) })
69
+ .select_append { min(start_date).as(:start_date) }
70
+ .select_append { max(end_date).as(:end_date) }
71
+ .select_append { sum(value_as_number).as(:value_as_number) }
72
+ .select_append(*nulls.map { |column| rdbms.process(column, nil) })
73
+ .from_self
74
+ end
75
+
76
+ def group_by_person?
77
+ options[:group_by_person]
78
+ end
79
+
39
80
  def unioned(db)
40
81
  upstreams.map { |c| c.evaluate(db).select(*query_cols) }.inject do |uni, q|
41
82
  uni.union(q.from_self)
@@ -99,8 +99,12 @@ module ConceptQL
99
99
  def apply_selectors(ds, opts = {})
100
100
  return ds unless include_rhs_columns
101
101
 
102
- ds = ds.select_remove(*include_rhs_columns).select_append(*rhs_columns(opts)) if include_rhs_columns
103
- ds
102
+ # A dataset without a select list reads an upstream, so it has the
103
+ # scope's columns. Name them: select_remove would ask the database
104
+ # for them, and the CTE of a labeled upstream does not exist while
105
+ # the SQL is built (bead conceptql-6k8).
106
+ ds = ds.select(*dynamic_columns) if ds.opts[:select].blank?
107
+ ds.select_remove(*include_rhs_columns).select_append(*rhs_columns(opts))
104
108
  end
105
109
 
106
110
  def rhs_columns(opts)