conceptql 0.2.0 → 0.4.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 (242) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +226 -117
  3. data/LICENSE +21 -0
  4. data/README.md +35 -83
  5. data/bin/conceptql +2 -1
  6. data/bin/conceptql_utils +6 -0
  7. data/config/multiple_vocabularies.csv +4 -0
  8. data/config/provenance.yml +489 -0
  9. data/config/race.yml +50 -0
  10. data/config/vocabularies.csv +87 -0
  11. data/lib/conceptql/behaviors/code_lister.rb +23 -0
  12. data/lib/conceptql/behaviors/drugish.rb +17 -0
  13. data/lib/conceptql/behaviors/labish.rb +19 -0
  14. data/lib/conceptql/behaviors/metadatable.rb +226 -73
  15. data/lib/conceptql/behaviors/provenanceable.rb +26 -0
  16. data/lib/conceptql/behaviors/single_event.rb +11 -0
  17. data/lib/conceptql/behaviors/timeless.rb +11 -0
  18. data/lib/conceptql/behaviors/utilizable.rb +206 -0
  19. data/lib/conceptql/behaviors/windowable.rb +27 -0
  20. data/lib/conceptql/cli.rb +117 -108
  21. data/lib/conceptql/columnizer.rb +75 -0
  22. data/lib/conceptql/data_model/base.rb +462 -0
  23. data/lib/conceptql/data_model/gdm.rb +155 -0
  24. data/lib/conceptql/data_model/gdm_wide.rb +42 -0
  25. data/lib/conceptql/data_model/omopv4_plus.rb +12 -0
  26. data/lib/conceptql/data_model.rb +30 -0
  27. data/lib/conceptql/database.rb +214 -0
  28. data/lib/conceptql/date_adjuster.rb +66 -18
  29. data/lib/conceptql/diagram.rb +243 -0
  30. data/lib/conceptql/lexicon/lexicon_gdm.rb +47 -0
  31. data/lib/conceptql/lexicon/lexicon_no_db.rb +49 -0
  32. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +63 -0
  33. data/lib/conceptql/lexicon/lexicon_strategy.rb +116 -0
  34. data/lib/conceptql/lexicon.rb +111 -0
  35. data/lib/conceptql/nodifier.rb +65 -24
  36. data/lib/conceptql/operators/after.rb +34 -14
  37. data/lib/conceptql/operators/any_overlap.rb +8 -6
  38. data/lib/conceptql/operators/before.rb +34 -10
  39. data/lib/conceptql/operators/binary_operator_operator.rb +66 -23
  40. data/lib/conceptql/operators/casting_operator.rb +51 -36
  41. data/lib/conceptql/operators/co_reported.rb +111 -0
  42. data/lib/conceptql/operators/concurrent_within.rb +69 -0
  43. data/lib/conceptql/operators/contains.rb +12 -8
  44. data/lib/conceptql/operators/count.rb +17 -9
  45. data/lib/conceptql/operators/date_range.rb +48 -14
  46. data/lib/conceptql/operators/death.rb +14 -4
  47. data/lib/conceptql/operators/dedup.rb +31 -0
  48. data/lib/conceptql/operators/during.rb +7 -12
  49. data/lib/conceptql/operators/episode.rb +146 -0
  50. data/lib/conceptql/operators/equal.rb +9 -3
  51. data/lib/conceptql/operators/ethnicity.rb +49 -0
  52. data/lib/conceptql/operators/except.rb +19 -12
  53. data/lib/conceptql/operators/filter.rb +23 -8
  54. data/lib/conceptql/operators/first.rb +16 -3
  55. data/lib/conceptql/operators/from.rb +52 -4
  56. data/lib/conceptql/operators/gender.rb +34 -11
  57. data/lib/conceptql/operators/hospice.rb +19 -0
  58. data/lib/conceptql/operators/hospitalization.rb +19 -0
  59. data/lib/conceptql/operators/information_periods.rb +36 -0
  60. data/lib/conceptql/operators/invalid.rb +35 -0
  61. data/lib/conceptql/operators/last.rb +16 -3
  62. data/lib/conceptql/operators/match.rb +91 -0
  63. data/lib/conceptql/operators/multiple_vocabularies.rb +172 -0
  64. data/lib/conceptql/operators/numeric.rb +65 -23
  65. data/lib/conceptql/operators/numeric_filter.rb +68 -0
  66. data/lib/conceptql/operators/occurrence.rb +153 -32
  67. data/lib/conceptql/operators/one_in_two_out.rb +237 -38
  68. data/lib/conceptql/operators/operator.rb +624 -178
  69. data/lib/conceptql/operators/pass_thru.rb +17 -3
  70. data/lib/conceptql/operators/person.rb +18 -6
  71. data/lib/conceptql/operators/person_filter.rb +9 -3
  72. data/lib/conceptql/operators/place_of_service_filter.rb +43 -0
  73. data/lib/conceptql/operators/provenance.rb +55 -0
  74. data/lib/conceptql/operators/provider_filter.rb +62 -0
  75. data/lib/conceptql/operators/race.rb +55 -7
  76. data/lib/conceptql/operators/read.rb +168 -0
  77. data/lib/conceptql/operators/recall.rb +49 -14
  78. data/lib/conceptql/operators/snf.rb +19 -0
  79. data/lib/conceptql/operators/sum.rb +29 -12
  80. data/lib/conceptql/operators/temporal_operator.rb +108 -16
  81. data/lib/conceptql/operators/time_window.rb +20 -32
  82. data/lib/conceptql/operators/trim_date.rb +71 -0
  83. data/lib/conceptql/operators/trim_date_end.rb +28 -40
  84. data/lib/conceptql/operators/trim_date_start.rb +28 -41
  85. data/lib/conceptql/operators/union.rb +67 -6
  86. data/lib/conceptql/operators/vocabulary.rb +151 -0
  87. data/lib/conceptql/paths.rb +41 -0
  88. data/lib/conceptql/query.rb +191 -26
  89. data/lib/conceptql/query_modifiers/gdm/admission_date_query_modifier.rb +46 -0
  90. data/lib/conceptql/query_modifiers/gdm/drug_query_modifier.rb +54 -0
  91. data/lib/conceptql/query_modifiers/gdm/lab_query_modifier.rb +57 -0
  92. data/lib/conceptql/query_modifiers/gdm/pos_query_modifier.rb +46 -0
  93. data/lib/conceptql/query_modifiers/gdm/provenance_query_modifier.rb +45 -0
  94. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +53 -0
  95. data/lib/conceptql/query_modifiers/gdm_wide/admission_date_query_modifier.rb +42 -0
  96. data/lib/conceptql/query_modifiers/gdm_wide/drug_query_modifier.rb +52 -0
  97. data/lib/conceptql/query_modifiers/gdm_wide/lab_query_modifier.rb +55 -0
  98. data/lib/conceptql/query_modifiers/gdm_wide/pos_query_modifier.rb +37 -0
  99. data/lib/conceptql/query_modifiers/gdm_wide/provenance_query_modifier.rb +38 -0
  100. data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +45 -0
  101. data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +80 -0
  102. data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +47 -0
  103. data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +45 -0
  104. data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +26 -0
  105. data/lib/conceptql/query_modifiers/query_modifier.rb +20 -0
  106. data/lib/conceptql/rdbms/duckdb.rb +25 -0
  107. data/lib/conceptql/rdbms/generic.rb +120 -0
  108. data/lib/conceptql/rdbms/postgres.rb +51 -0
  109. data/lib/conceptql/rdbms/spark.rb +22 -0
  110. data/lib/conceptql/rdbms.rb +22 -0
  111. data/lib/conceptql/scope.rb +481 -31
  112. data/lib/conceptql/sql_formatters/formatter.rb +25 -0
  113. data/lib/conceptql/sql_formatters.rb +22 -0
  114. data/lib/conceptql/utils.rb +113 -0
  115. data/lib/conceptql/utils_cli.rb +17 -0
  116. data/lib/conceptql/version.rb +3 -1
  117. data/lib/conceptql/visitors/metadata_extractor.rb +54 -0
  118. data/lib/conceptql/vocabularies/behaviors/costish.rb +24 -0
  119. data/lib/conceptql/vocabularies/behaviors/gdmish.rb +19 -0
  120. data/lib/conceptql/vocabularies/behaviors/omopish.rb +32 -0
  121. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +63 -0
  122. data/lib/conceptql/vocabularies/entry.rb +138 -0
  123. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +85 -0
  124. data/lib/conceptql/window/base.rb +26 -0
  125. data/lib/conceptql/window/date_range.rb +25 -0
  126. data/lib/conceptql/window/table.rb +116 -0
  127. data/lib/conceptql/window.rb +72 -0
  128. data/lib/conceptql.rb +65 -2
  129. data/lib/sequel/extensions/pg_ctas_explain.rb +22 -0
  130. data/lib/sequel/extensions/pg_vacuum_table.rb +23 -0
  131. data/schemas/gdm.yml +882 -0
  132. data/schemas/gdm_wide.yml +757 -0
  133. data/schemas/ohdsi_vocabs.yml +143 -0
  134. data/schemas/omopv4_plus.yml +729 -0
  135. data/schemas/omopv4_plus_more_ignorables.tsv +13 -0
  136. metadata +267 -207
  137. data/.gitignore +0 -23
  138. data/Gemfile +0 -4
  139. data/Guardfile +0 -56
  140. data/LICENSE.txt +0 -22
  141. data/Rakefile +0 -1
  142. data/conceptql.gemspec +0 -32
  143. data/doc/ConceptQL Specification (alpha).pdf +0 -0
  144. data/doc/diagram_0.png +0 -0
  145. data/doc/implementation_notes.md +0 -39
  146. data/doc/spec.md +0 -1344
  147. data/lib/conceptql/behaviors/debuggable.rb +0 -70
  148. data/lib/conceptql/behaviors/dottable.rb +0 -103
  149. data/lib/conceptql/behaviors/preppable.rb +0 -20
  150. data/lib/conceptql/converter.rb +0 -65
  151. data/lib/conceptql/debugger.rb +0 -48
  152. data/lib/conceptql/fake_grapher.rb +0 -20
  153. data/lib/conceptql/graph.rb +0 -52
  154. data/lib/conceptql/graph_nodifier.rb +0 -203
  155. data/lib/conceptql/logger.rb +0 -10
  156. data/lib/conceptql/operators/complement.rb +0 -38
  157. data/lib/conceptql/operators/concept.rb +0 -70
  158. data/lib/conceptql/operators/condition_type.rb +0 -71
  159. data/lib/conceptql/operators/cpt.rb +0 -25
  160. data/lib/conceptql/operators/drug_type_concept.rb +0 -21
  161. data/lib/conceptql/operators/from_seer_visits.rb +0 -23
  162. data/lib/conceptql/operators/hcpcs.rb +0 -25
  163. data/lib/conceptql/operators/icd10.rb +0 -28
  164. data/lib/conceptql/operators/icd9.rb +0 -28
  165. data/lib/conceptql/operators/icd9_procedure.rb +0 -25
  166. data/lib/conceptql/operators/intersect.rb +0 -34
  167. data/lib/conceptql/operators/loinc.rb +0 -25
  168. data/lib/conceptql/operators/medcode.rb +0 -28
  169. data/lib/conceptql/operators/medcode_procedure.rb +0 -27
  170. data/lib/conceptql/operators/ndc.rb +0 -29
  171. data/lib/conceptql/operators/observation_by_enttype.rb +0 -29
  172. data/lib/conceptql/operators/observation_period.rb +0 -30
  173. data/lib/conceptql/operators/overlapped_by.rb +0 -23
  174. data/lib/conceptql/operators/overlaps.rb +0 -19
  175. data/lib/conceptql/operators/place_of_service_code.rb +0 -24
  176. data/lib/conceptql/operators/procedure_occurrence.rb +0 -25
  177. data/lib/conceptql/operators/prodcode.rb +0 -29
  178. data/lib/conceptql/operators/rxnorm.rb +0 -24
  179. data/lib/conceptql/operators/snomed.rb +0 -24
  180. data/lib/conceptql/operators/snomed_condition.rb +0 -26
  181. data/lib/conceptql/operators/source_vocabulary_operator.rb +0 -58
  182. data/lib/conceptql/operators/standard_vocabulary_operator.rb +0 -45
  183. data/lib/conceptql/operators/started_by.rb +0 -22
  184. data/lib/conceptql/operators/to_seer_visits.rb +0 -24
  185. data/lib/conceptql/operators/visit.rb +0 -15
  186. data/lib/conceptql/operators/visit_occurrence.rb +0 -30
  187. data/lib/conceptql/tree.rb +0 -53
  188. data/lib/conceptql/utils/temp_table.rb +0 -72
  189. data/spec/conceptql/behaviors/dottable_spec.rb +0 -99
  190. data/spec/conceptql/converter_spec.rb +0 -51
  191. data/spec/conceptql/date_adjuster_spec.rb +0 -68
  192. data/spec/conceptql/operators/after_spec.rb +0 -16
  193. data/spec/conceptql/operators/before_spec.rb +0 -16
  194. data/spec/conceptql/operators/casting_operator_spec.rb +0 -68
  195. data/spec/conceptql/operators/complement_spec.rb +0 -15
  196. data/spec/conceptql/operators/concept_spec.rb +0 -40
  197. data/spec/conceptql/operators/condition_type_spec.rb +0 -128
  198. data/spec/conceptql/operators/contains_spec.rb +0 -19
  199. data/spec/conceptql/operators/cpt_spec.rb +0 -29
  200. data/spec/conceptql/operators/date_range_spec.rb +0 -33
  201. data/spec/conceptql/operators/death_spec.rb +0 -10
  202. data/spec/conceptql/operators/during_spec.rb +0 -30
  203. data/spec/conceptql/operators/except_spec.rb +0 -15
  204. data/spec/conceptql/operators/first_spec.rb +0 -35
  205. data/spec/conceptql/operators/from_spec.rb +0 -13
  206. data/spec/conceptql/operators/gender_spec.rb +0 -27
  207. data/spec/conceptql/operators/hcpcs_spec.rb +0 -29
  208. data/spec/conceptql/operators/icd10_spec.rb +0 -34
  209. data/spec/conceptql/operators/icd9_procedure_spec.rb +0 -29
  210. data/spec/conceptql/operators/icd9_spec.rb +0 -34
  211. data/spec/conceptql/operators/intersect_spec.rb +0 -28
  212. data/spec/conceptql/operators/last_spec.rb +0 -36
  213. data/spec/conceptql/operators/loinc_spec.rb +0 -29
  214. data/spec/conceptql/operators/medcode_procedure_spec.rb +0 -34
  215. data/spec/conceptql/operators/medcode_spec.rb +0 -34
  216. data/spec/conceptql/operators/observation_period_spec.rb +0 -10
  217. data/spec/conceptql/operators/occurrence_spec.rb +0 -87
  218. data/spec/conceptql/operators/overlapped_by_spec.rb +0 -32
  219. data/spec/conceptql/operators/overlaps_spec.rb +0 -21
  220. data/spec/conceptql/operators/person_filter_spec.rb +0 -15
  221. data/spec/conceptql/operators/person_spec.rb +0 -10
  222. data/spec/conceptql/operators/place_of_service_code_spec.rb +0 -24
  223. data/spec/conceptql/operators/procedure_occurrence_spec.rb +0 -10
  224. data/spec/conceptql/operators/prodcode_spec.rb +0 -35
  225. data/spec/conceptql/operators/query_double.rb +0 -20
  226. data/spec/conceptql/operators/query_double_spec.rb +0 -7
  227. data/spec/conceptql/operators/race_spec.rb +0 -21
  228. data/spec/conceptql/operators/rxnorm_spec.rb +0 -29
  229. data/spec/conceptql/operators/snomed_spec.rb +0 -29
  230. data/spec/conceptql/operators/source_vocabulary_operator_spec.rb +0 -35
  231. data/spec/conceptql/operators/standard_vocabulary_operator_spec.rb +0 -35
  232. data/spec/conceptql/operators/started_by_spec.rb +0 -22
  233. data/spec/conceptql/operators/temporal_operator_spec.rb +0 -51
  234. data/spec/conceptql/operators/time_window_spec.rb +0 -77
  235. data/spec/conceptql/operators/union_spec.rb +0 -21
  236. data/spec/conceptql/operators/visit_occurrence_spec.rb +0 -10
  237. data/spec/conceptql/query_spec.rb +0 -22
  238. data/spec/conceptql/tree_spec.rb +0 -50
  239. data/spec/doubles/stream_for_casting_double.rb +0 -9
  240. data/spec/doubles/stream_for_occurrence_double.rb +0 -25
  241. data/spec/doubles/stream_for_temporal_double.rb +0 -6
  242. data/spec/spec_helper.rb +0 -102
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "binary_operator_operator"
4
+
5
+ module ConceptQL
6
+ module Operators
7
+ # Compares the columns from #columns. Without options, that is every query
8
+ # column, so a stream matched with itself keeps every record.
9
+ class Match < BinaryOperatorOperator
10
+ register __FILE__
11
+
12
+ desc "Keeps left hand records that have the same values as a right hand side record. By default, every column is compared. only_columns or except_columns narrows that set, and join_columns compares only the person and the listed columns. Two NULL values count as the same value."
13
+ default_query_columns
14
+
15
+ # match reads only_columns, except_columns, join_columns and
16
+ # invert_match, but does not declare them with `option`: only t_shank
17
+ # gives them, and a declared option would appear in ConceptQL.metadata,
18
+ # so the diagram editor would offer it (Ryan, 2026-10-26). Each column
19
+ # list is an array of column names or a comma-separated string (see
20
+ # Operator#column_names_option).
21
+
22
+ # These columns identify a record and are never NULL, so match compares
23
+ # them with =. It compares every other column NULL-safe: the value
24
+ # columns are often NULL, and with = a NULL never equals a NULL.
25
+ KEY_COLUMNS = %i[person_id criterion_id criterion_table criterion_domain start_date uuid window_id].freeze
26
+
27
+ def query(db)
28
+ rhs = right.evaluate(db)
29
+ rhs = rhs.from_self.select_group(*columns)
30
+ query = db.from(Sequel.as(left.evaluate(db), :l))
31
+
32
+ join_check = columns.map { |column| column_check(column) }.inject(&:&)
33
+ sub_select = rhs.from_self(alias: :r)
34
+ .select(1)
35
+ .where(join_check)
36
+
37
+ query.send(where_method(:where), sub_select.exists).select_all(:l)
38
+ end
39
+
40
+ def columns
41
+ @columns ||= determine_columns
42
+ end
43
+
44
+ # join_columns has the meaning that BinaryOperatorOperator gives it:
45
+ # the person (and window) columns plus the listed columns.
46
+ def determine_columns
47
+ join_columns = column_names_option(:join_columns)
48
+ return (matching_columns + join_columns).uniq if join_columns
49
+
50
+ only_columns = column_names_option(:only_columns)
51
+ except_columns = column_names_option(:except_columns)
52
+ columns = dynamic_columns
53
+ columns &= only_columns if only_columns
54
+ columns -= except_columns if except_columns
55
+ columns
56
+ end
57
+
58
+ def where_method(meth)
59
+ return meth unless invert_match
60
+
61
+ meth == :where ? :exclude : :where
62
+ end
63
+
64
+ # Existing statements give the string "true". The string "false" (in any
65
+ # case, with any spaces around it) and a blank value do not invert.
66
+ def invert_match
67
+ text = options[:invert_match].to_s.strip
68
+ !(text.empty? || text.casecmp?("false"))
69
+ end
70
+
71
+ private
72
+
73
+ def column_check(column)
74
+ left_column = Sequel[:l][column]
75
+ right_column = Sequel[:r][column]
76
+ return Sequel.expr([ [ left_column, right_column ] ]) if KEY_COLUMNS.include?(column)
77
+
78
+ rdbms.null_safe_equal(left_column, right_column)
79
+ end
80
+
81
+ # join_columns sets the whole column set, so only_columns and
82
+ # except_columns would have no effect next to it.
83
+ def additional_validation(_db, _opts = {})
84
+ return unless column_names_option(:join_columns)
85
+
86
+ conflicts = %i[only_columns except_columns].select { |name| column_names_option(name) }
87
+ add_error("conflicting options", "join_columns", *conflicts.map(&:to_s)) unless conflicts.empty?
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module Operators
5
+ class MultipleVocabularies < Operator
6
+ include ConceptQL::Behaviors::Windowable
7
+ include ConceptQL::Behaviors::CodeLister
8
+
9
+ class << self
10
+ def multiple_vocabularies
11
+ @multiple_vocabularies ||= get_multiple_vocabularies
12
+ end
13
+
14
+ def get_multiple_vocabularies
15
+ [ ConceptQL.multiple_vocabularies_file_path,
16
+ ConceptQL.custom_multiple_vocabularies_file_path ].select(&:exist?).map do |path|
17
+ CSV.foreach(path, headers: true, header_converters: :symbol).each_with_object({}) do |row, h|
18
+ (h[operator_symbol(row[:operator])] ||= []) << row.to_hash
19
+ end
20
+ end.inject(&:merge)
21
+ end
22
+
23
+ def register_many
24
+ multiple_vocabularies.each_key do |operator_sym|
25
+ register(operator_sym)
26
+ end
27
+ end
28
+
29
+ def operator_symbol(word)
30
+ word.gsub(/\W+/, "_").downcase
31
+ end
32
+
33
+ # This will override the to_metadata method and return the preferred name
34
+ # based on the name listed in the file.
35
+ #
36
+ # This method will be called once for each vocabulary we register
37
+ # for this operator
38
+ def to_metadata(name, opts = {})
39
+ h = super
40
+ op_info = multiple_vocabularies[name].first
41
+ h[:preferred_name] = op_info[:operator]
42
+ h[:predominant_domains] = multiple_vocabularies[name].map { |mv| mv[:domain] }.uniq.compact
43
+ h
44
+ end
45
+ end
46
+
47
+ register_many
48
+
49
+ desc "Selects records based on the given vocabularies."
50
+ argument :codes, type: :codelist
51
+ basic_type :selection
52
+ category "Select by Clinical Codes"
53
+ validate_no_upstreams
54
+ validate_at_least_one_argument
55
+ conceptql_spec_id "vocabulary"
56
+
57
+ def query(db)
58
+ # TODO: A much-more efficient method would be to find all those vocabs
59
+ # sharing a common table and feed them into a single single query,
60
+ # but I think this would require some revamping of Vocabulary, and I'm
61
+ # just not interested in taking that on right now.
62
+ vocab_ops.map { |vo| vo.evaluate(db) }.inject do |union, q|
63
+ union.union(q.from_self)
64
+ end
65
+ end
66
+
67
+ def domains(_db)
68
+ vocab_ops.map(&:domain).uniq
69
+ end
70
+
71
+ # Each vocabulary operator in vocab_ops has already applied the scope's
72
+ # windows. Applying them again to the union multiplied rows: an event in
73
+ # k overlapping windows came out k*k times instead of k (conceptql-7cd).
74
+ def skip_windows?
75
+ true
76
+ end
77
+
78
+ def source_table
79
+ nil
80
+ end
81
+
82
+ def table
83
+ nil
84
+ end
85
+
86
+ def domain
87
+ nil
88
+ end
89
+
90
+ def tables
91
+ []
92
+ end
93
+
94
+ # 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
99
+
100
+ def additional_validation(db, opts = {})
101
+ vocab_ops.each { |vo| vo.valid?(db, opts) }
102
+
103
+ @errors += aggregate_messages(vocab_ops.map(&:errors))
104
+ @warnings += aggregate_messages(vocab_ops.map { |vo| vo.warnings.reject { |w| CODE_WARNINGS.include?(w.first) } })
105
+ @warnings += code_warnings
106
+ end
107
+
108
+ # A code gets a warning only when every vocabulary rejects it, for any
109
+ # reason. It is an improperly formatted code only when every vocabulary
110
+ # says so. When at least one vocabulary accepts its format but does not
111
+ # know it, it is an unknown code. (12345 is badly formatted for HCPCS but
112
+ # a well-formed, unknown CPT4 code, so it is unknown.)
113
+ def code_warnings
114
+ verdicts = vocab_ops.map do |vo|
115
+ vo.warnings.each_with_object({}) do |(message, *codes), verdict|
116
+ codes.each { |code| verdict[code] ||= message } if CODE_WARNINGS.include?(message)
117
+ end
118
+ end
119
+
120
+ 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 } }
122
+
123
+ [ [ BAD_FORMAT, *bad_format ], [ UNKNOWN_CODE, *unknown ] ].reject { |warning| warning.length == 1 }
124
+ end
125
+
126
+ def aggregate_messages(message_sets)
127
+ messages = message_sets.map do |message_set|
128
+ message_set.to_h { |message, *args| [ message, args ] }
129
+ end
130
+
131
+ messages_keys = messages.map(&:keys).inject(:|)
132
+
133
+ messages = messages_keys.each.with_object({}) do |key, h|
134
+ h[key] = messages.map { |w| w[key] || [] }.inject(:&)
135
+ end
136
+
137
+ messages = messages.reject { |_, v| v.empty? }
138
+
139
+ messages.map { |k, v| [ k, *v ] }
140
+ end
141
+
142
+ def describe_codes(db, codes)
143
+ codes = vocab_ops.flat_map do |vo|
144
+ vo.describe_codes(db, codes)
145
+ end
146
+
147
+ # The first vocabulary that describes a code wins, so CPT4_HCPCS adds
148
+ # descriptions only for codes that CPT4 and HCPCS do not know.
149
+ codes_with_descriptions = codes.select(&:last).uniq(&:first)
150
+ codes_without_descriptions = codes.reject(&:last).map(&:first)
151
+ codes_without_descriptions -= codes_with_descriptions.map(&:first)
152
+ codes_with_descriptions + codes_without_descriptions.zip([])
153
+ end
154
+
155
+ # An optional vocabulary (CPT4_HCPCS) has an operator only on a Database
156
+ # whose lexicon carries it, so it is skipped elsewhere. A required one
157
+ # that is missing still raises.
158
+ def vocab_ops
159
+ @vocab_ops ||= self.class.multiple_vocabularies[op_name].filter_map do |op_info|
160
+ name = op_info[:vocabulary_id].to_s.downcase
161
+ next if op_info[:optional] == "yes" && !nodifier.operators.key?(name)
162
+
163
+ nodifier.operators.fetch(name).new(nodifier, name, *arguments)
164
+ end
165
+ end
166
+
167
+ def preferred_name
168
+ self.class.multiple_vocabularies[op_name].first[:operator]
169
+ end
170
+ end
171
+ end
172
+ end
@@ -1,10 +1,12 @@
1
- require_relative 'pass_thru'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pass_thru"
2
4
 
3
5
  module ConceptQL
4
6
  module Operators
5
7
  # Represents a operator that will either:
6
8
  # - create a value_as_number value for every person in the database
7
- # - change the value_as_number value for every every result passed in
9
+ # - change the value_as_number value for every every record passed in
8
10
  # - either to a number
9
11
  # - or a value from a column in the origin row
10
12
  #
@@ -12,43 +14,83 @@ module ConceptQL
12
14
  # - Either a number value or a symbol representing a column name
13
15
  # - An optional stream
14
16
  class Numeric < PassThru
15
- desc <<-EOF
16
- Represents a operator that will either:
17
- - create a value_as_number value for every person in the database
18
- - change the value_as_number value for every every result passed in
19
- - either to a number
20
- - or a value from a column in the origin row
21
-
22
- Accepts two params:
23
- - Either a number value or a symbol representing a column name
24
- - An optional stream
17
+ register __FILE__
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
25
29
  EOF
26
30
  argument :value, type: :float
27
31
  allows_one_upstream
32
+ validate_at_most_one_upstream
33
+ validate_one_argument
34
+ default_query_columns
35
+ require_column :value_as_number
28
36
 
29
37
  def query(db)
30
38
  stream.nil? ? as_criterion(db) : with_kids(db)
31
39
  end
32
40
 
33
- def types
34
- stream.nil? ? [:person] : super
41
+ def domains(db)
42
+ stream.nil? ? [ :person ] : super
43
+ end
44
+
45
+ # Without an upstream, Numeric emits exactly one row per patient via the
46
+ # as_criterion path (SELECT value_as_number FROM patients). The leaf
47
+ # default in Operator#events_per_patient assumes :multiple, which is wrong
48
+ # for this single-row-per-patient shape. Override here so that downstream
49
+ # operators (Union, etc.) can correctly conclude that a numeric leaf is
50
+ # single-event-per-patient.
51
+ def events_per_patient
52
+ stream.nil? ? :single : super
35
53
  end
36
54
 
37
55
  private
56
+
38
57
  def with_kids(db)
39
- db.from(stream.evaluate(db))
40
- .select(*(COLUMNS - [:value_as_number]))
41
- .select_append(Sequel.lit('?', arguments.first).cast(Float).as(:value_as_number))
42
- .from_self
58
+ dm.selectify(db.from(stream.evaluate(db)), replace: { value_as_number: first_argument })
43
59
  end
44
60
 
45
61
  def as_criterion(db)
46
- db.from(select_it(db.from(:person), :person))
47
- .select(*(COLUMNS - [:value_as_number]))
48
- .select_append(Sequel.lit('?', arguments.first).cast(Float).as(:value_as_number))
49
- .from_self
62
+ # Explicitly set column_family to DEFAULT_COLUMN_FAMILY here. The
63
+ # selectify path through `domain: :person` runs against the patients
64
+ # table, which doesn't have a column_family column. Without this
65
+ # replace, nullified_columns inserts CAST(NULL AS text) AS
66
+ # "column_family", and the outer evaluate -> select_it wrapper does not
67
+ # re-inject a value (Numeric has no source_table/table/domain methods,
68
+ # so determine_table returns nil and select_it's `cf =
69
+ # DEFAULT_COLUMN_FAMILY` branch never fires). Result: every Numeric
70
+ # as_criterion row carried column_family = NULL. Explicitly replacing
71
+ # it here keeps the fix at the same level as Episode#query, which
72
+ # already sets column_family in its replace hash for the same reason.
73
+ dm.selectify(db.from(dm.table_by_domain(:person)),
74
+ domain: :person,
75
+ replace: {
76
+ value_as_number: first_argument,
77
+ column_family: ConceptQL::Scope::DEFAULT_COLUMN_FAMILY
78
+ })
79
+ end
80
+
81
+ def first_argument
82
+ value = arguments.first.to_s.strip
83
+ value = nil if value.blank?
84
+ if !value || is_a_number?(value)
85
+ Sequel.cast_numeric(value, Float)
86
+ else
87
+ Sequel.identifier(value)
88
+ end
89
+ end
90
+
91
+ def is_a_number?(value)
92
+ value =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/
50
93
  end
51
94
  end
52
95
  end
53
96
  end
54
-
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "operator"
4
+
5
+ module ConceptQL
6
+ module Operators
7
+ # Filters the incoming stream of records to only those that have a
8
+ # value_as_number column with a value that matches the specified criteria
9
+ # are passed through
10
+ #
11
+ # If an record has NULL for value_as_number, it is filtered out.
12
+ class NumericFilter < Operator
13
+ register __FILE__
14
+
15
+ desc "Includes records with a value_as_number that match the given criteria."
16
+
17
+ option :greater_than_or_equal_to, type: :float
18
+ option :less_than_or_equal_to, type: :float
19
+ category "Filter Single Stream"
20
+ basic_type :temporal
21
+ allows_one_upstream
22
+ validate_one_upstream
23
+ require_column :value_as_number
24
+ default_query_columns
25
+
26
+ VALUE_COLUMN = Sequel[:value_as_number]
27
+
28
+ def query(db)
29
+ db.from(stream.evaluate(db))
30
+ .where(range_criteria)
31
+ end
32
+
33
+ private
34
+
35
+ def range_criteria
36
+ criteria = []
37
+ criteria << Sequel.~(VALUE_COLUMN => nil)
38
+ criteria << gte_criteria if gte.present?
39
+ criteria << lte_criteria if lte.present?
40
+ criteria.inject(&:&)
41
+ end
42
+
43
+ def gte
44
+ options[:greater_than_or_equal_to]
45
+ end
46
+
47
+ def lte
48
+ options[:less_than_or_equal_to]
49
+ end
50
+
51
+ def gte_literal
52
+ Sequel.cast_numeric(gte.to_f, Float)
53
+ end
54
+
55
+ def lte_literal
56
+ Sequel.cast_numeric(lte.to_f, Float)
57
+ end
58
+
59
+ def gte_criteria
60
+ gte_literal <= VALUE_COLUMN
61
+ end
62
+
63
+ def lte_criteria
64
+ lte_literal >= VALUE_COLUMN
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,4 +1,7 @@
1
- require_relative 'operator'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "operator"
4
+ require_relative "../behaviors/single_event"
2
5
 
3
6
  module ConceptQL
4
7
  module Operators
@@ -16,55 +19,173 @@ module ConceptQL
16
19
  # by start_date and then select at most one row per person, regardless
17
20
  # of how many different types of streams enter the operator
18
21
  #
19
- # If two rows have the same start_date, the order of their ranking
20
- # is arbitrary
22
+ # If two rows have the same start_date, criterion_id breaks the tie.
23
+ # criterion_id is unique within one criterion_table, so only rows from
24
+ # two different tables with the same id and start_date still rank in
25
+ # arbitrary order
21
26
  #
22
27
  # If we ask for the second occurrence of something and a person has only one
23
28
  # occurrence, this operator returns nothing for that person
24
29
  class Occurrence < Operator
25
- preferred_name 'Nth Occurrence'
26
- desc <<-EOF
27
- Groups all results by person, then orders by start_date, then finds the nth occurrence.
28
- nth occurrence can be positive or negative.
29
- 1 => first
30
- 2 => second
31
- ...
32
- -1 => last
33
- -2 => second-to-last
34
-
35
- If two rows have the same start_date, the order of their ranking
36
- is arbitrary
37
-
38
- If we ask for the second occurrence of something and a person has only one
39
- occurrence, this operator returns nothing for that person
40
- EOF
30
+ include ConceptQL::Behaviors::SingleEvent
31
+
32
+ register __FILE__
33
+
34
+ preferred_name "Nth Occurrence"
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'."
37
+
41
38
  argument :occurrence, type: :integer
39
+ category "Filter Single Stream"
40
+ basic_type :temporal
42
41
  allows_one_upstream
43
- category %w(Temporal Occurrence)
42
+ validate_at_least_one_upstream
44
43
 
45
- def query(db)
46
- db[:occurrences]
47
- .with(:occurrences,
48
- stream.evaluate(db)
49
- .from_self
50
- .select_append { |o| o.row_number(:over, partition: :person_id, order: ordered_columns){}.as(:rn) })
51
- .where(rn: occurrence.abs)
44
+ option :at_least, type: :string,
45
+ instructions: 'Enter a numeric value and specify "d", "m", or "y" for "days", "months", or "years". Negative numbers change dates prior to the existing date. Example: -30d = 30 days before the existing date.'
46
+ option :within, type: :string,
47
+ instructions: 'Enter a numeric value and specify "d", "m", or "y" for "days", "months", or "years". Negative numbers change dates prior to the existing date. Example: -30d = 30 days before the existing date.'
48
+ option :group_by_date, type: :boolean,
49
+ instructions: "Choose whether to group by date when determining the nth occurrence, treating occurrences on the same day as a single occurrence"
50
+
51
+ def query_cols
52
+ dynamic_columns + [ :rn ]
52
53
  end
53
54
 
54
55
  def occurrence
55
56
  @occurrence ||= arguments.first
56
57
  end
57
58
 
59
+ def query(db)
60
+ ds = stream.evaluate(db)
61
+ additional_matching_columns = []
62
+ additional_matching_columns << :start_date if grouped?
63
+
64
+ ds = ds.select_append(
65
+ Sequel[:ROW_NUMBER].function.over(
66
+ partition: matching_columns_plus(*additional_matching_columns),
67
+ order: ordered_columns(:criterion_id)
68
+ ).as(:rn)
69
+ )
70
+ ds = ds.from_self
71
+ return ds.where(rn: occurrence.abs) unless timed? || grouped?
72
+
73
+ additional_order_columns = []
74
+
75
+ if grouped?
76
+ one_date_ds = ds.from_self.where(rn: 1).from_self.select_remove(:rn)
77
+ ds = one_date_ds.select_append(
78
+ Sequel[:ROW_NUMBER].function.over(
79
+ partition: matching_columns_plus,
80
+ order: ordered_columns
81
+ ).as(:rn)
82
+ ).from_self
83
+ end
84
+ return ds.where(rn: occurrence.abs) unless timed?
85
+
86
+ additional_order_columns << :criterion_id
87
+
88
+ input_name = cte_name(:occurrence_input)
89
+ joined_name = cte_name(:occurrence_joined)
90
+
91
+ input_ds = ds.select_append(
92
+ Sequel[:ROW_NUMBER].function.over(
93
+ partition: matching_columns_plus,
94
+ order: ordered_columns(*additional_order_columns, allow_reverse: false)
95
+ ).as(:global_rn)
96
+ )
97
+
98
+ first_ds = db[input_name].from_self(alias: :first)
99
+ first = Sequel[:first]
100
+ rest = Sequel[:rest]
101
+
102
+ joined_ds = first_ds.join(input_name, matching_columns_plus.map { |c| [ c, c ] }, table_alias: :rest) do
103
+ cond = rest[:global_rn] > first[:global_rn]
104
+ cond &= rest[:start_date] >= adjust_date(at_least_option, first[:end_date]) if at_least_option
105
+ cond &= rest[:start_date] <= adjust_date(within_option, first[:end_date]) if within_option
106
+ cond | { rest[:global_rn] => first[:global_rn] }
107
+ end
108
+ .select_all(:rest)
109
+ .select_append(first[:global_rn].as(:initial_rn))
110
+ .select_append(
111
+ Sequel[:ROW_NUMBER].function.over(
112
+ partition: matching_columns_plus(:global_rn).map { |c| first[c] },
113
+ order: ordered_columns(*additional_order_columns, qualifier: :rest)
114
+ ).as(:timed_rn)
115
+ )
116
+
117
+ db[joined_name]
118
+ .with(input_name, input_ds)
119
+ .with(joined_name, joined_ds)
120
+ .where(timed_rn: occurrence.abs)
121
+ end
122
+
123
+ def grouped?
124
+ options[:group_by_date]
125
+ end
126
+
127
+ # A blank at_least or within counts as not set. An editor can send
128
+ # a blank value for an empty field.
129
+ def timed?
130
+ !(at_least_option || within_option).nil?
131
+ end
132
+
133
+ def matching_columns_plus(*columns)
134
+ matching_columns + columns
135
+ end
136
+
58
137
  private
138
+
139
+ def within_option
140
+ return unless (v = options[:within])
141
+ return if v.strip.empty?
142
+
143
+ v
144
+ end
145
+
146
+ def at_least_option
147
+ return unless (v = options[:at_least])
148
+ return if v.strip.empty?
149
+
150
+ v
151
+ end
152
+
153
+ def adjust_date(adjustment, column, reverse = false)
154
+ adjuster = DateAdjuster.new(self, adjustment)
155
+ adjuster.adjust(column, reverse)
156
+ end
157
+
158
+ def additional_validation(_db, _opts = {})
159
+ if instance_of?(Occurrence)
160
+ validate_one_argument
161
+ else
162
+ validate_no_arguments
163
+ end
164
+ end
165
+
59
166
  def asc_or_desc
60
- occurrence < 0 ? :desc : :asc
167
+ occurrence.negative? ? :desc : :asc
168
+ end
169
+
170
+ def qualify_with(qualifier, col)
171
+ if qualifier
172
+ Sequel.qualify(qualifier, col)
173
+ else
174
+ col
175
+ end
61
176
  end
62
177
 
63
- def ordered_columns
64
- ordered_columns = [Sequel.send(asc_or_desc, :start_date)]
65
- ordered_columns += [:criterion_type, :criterion_id]
178
+ def ordered_columns(*columns, qualifier: nil, allow_reverse: true)
179
+ start_date = qualify_with(qualifier, :start_date)
180
+ start_date = Sequel.send(asc_or_desc, start_date) if allow_reverse
181
+ order = [ start_date ]
182
+ order += columns.map do |c|
183
+ c = qualify_with(qualifier, c)
184
+ c = Sequel.send(asc_or_desc, c) if allow_reverse
185
+ c
186
+ end
187
+ order
66
188
  end
67
189
  end
68
190
  end
69
191
  end
70
-