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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generic"
4
+
5
+ module ConceptQL
6
+ module Rdbms
7
+ class Postgres < Generic
8
+ # Returns from - to (see Generic#datediff).
9
+ def datediff(from, to)
10
+ Sequel.extract(:days, Sequel.cast(from, Time) - Sequel.cast(to, Time))
11
+ end
12
+
13
+ def post_create(db, table_name)
14
+ db.vacuum_table(table_name, analyze: true) if analyze_temp_tables?
15
+ end
16
+
17
+ def preferred_formatter
18
+ SqlFormatters::POSTGRES
19
+ end
20
+
21
+ # DISTINCT ON is Postgres-only. The other adapters inherit
22
+ # Generic#primary_concepts, which uses ROW_NUMBER() instead.
23
+ def primary_concepts(db, all_primary_ids)
24
+ db[Sequel[:clinical_codes].as(:pcc)]
25
+ .where(provenance_concept_id: all_primary_ids)
26
+ .select(
27
+ Sequel[:pcc][:collection_id].as(:collection_id),
28
+ Sequel[:pcc][:clinical_code_concept_id].as(:concept_id),
29
+ Sequel[:pcc][:clinical_code_source_value].as(:concept_code),
30
+ Sequel[:pcc][:clinical_code_vocabulary_id].as(:vocabulary_id),
31
+ Sequel[true].as(:is_primary)
32
+ )
33
+ .order(Sequel[:pcc][:collection_id], Sequel[:pcc][:clinical_code_concept_id])
34
+ .from_self
35
+ .distinct(:collection_id)
36
+ end
37
+
38
+ def analyze_temp_tables?
39
+ ENV["CONCEPTQL_PG_ANALYZE_TEMP_TABLES"] == "true"
40
+ end
41
+
42
+ def explain_temp_tables?
43
+ ENV["CONCEPTQL_PG_EXPLAIN_TEMP_TABLES"] == "true"
44
+ end
45
+
46
+ def supports_materialized?
47
+ true
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generic"
4
+
5
+ module ConceptQL
6
+ module Rdbms
7
+ class Spark < Generic
8
+ def days_between(from_column, to_column)
9
+ Sequel.function(:datediff, cast_date(to_column), cast_date(from_column))
10
+ end
11
+
12
+ # <=> is Spark's NULL-safe equality operator (see Generic#null_safe_equal).
13
+ def null_safe_equal(lhs, rhs)
14
+ Sequel.lit("(? <=> ?)", lhs, rhs)
15
+ end
16
+
17
+ def preferred_formatter
18
+ SqlFormatters::PG_FORMAT
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rdbms/postgres"
4
+ require_relative "rdbms/spark"
5
+
6
+ module ConceptQL
7
+ module Rdbms
8
+ def self.generate(database_type)
9
+ case database_type.to_sym
10
+ when :postgres
11
+ ConceptQL::Rdbms::Postgres.new
12
+ when :spark
13
+ ConceptQL::Rdbms::Spark.new
14
+ when :duckdb
15
+ require_relative "rdbms/duckdb"
16
+ ConceptQL::Rdbms::DuckDB.new
17
+ else
18
+ raise "Unknown database_type -- '#{database_type}'"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,6 +1,12 @@
1
- require_relative 'utils/temp_table'
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require_relative "window"
5
+ require "securerandom"
6
+ require "sequel"
7
+
2
8
  module ConceptQL
3
- # Scope coordinates the creation of any TempTables that might
9
+ # Scope coordinates the creation of any common table expressions that might
4
10
  # be used when a Recall operator is present in the statement.
5
11
  #
6
12
  # Any time an operator is given a label, it becomes a candidate
@@ -8,62 +14,506 @@ module ConceptQL
8
14
  # somewhere else in the statement.
9
15
  #
10
16
  # Scope keeps track of all labeled operators and provides an
11
- # API for Recall operators to fetch the results/types from
17
+ # API for Recall operators to fetch the results/domains from
12
18
  # labeled operators.
13
19
  class Scope
20
+ # Raised when the extracted CTEs read each other in a cycle, so no order
21
+ # puts every CTE after the CTEs it reads.
22
+ class CteCycleError < StandardError
23
+ def initialize(names)
24
+ super("These CTEs read each other in a cycle, so they have no valid order: #{names.join(', ')}")
25
+ end
26
+ end
27
+
28
+ DEFAULT_COLUMN_FAMILY = "default"
29
+
30
+ DEFAULT_COLUMNS = {
31
+ person_id: :Bigint,
32
+ criterion_id: :Bigint,
33
+ criterion_table: :String,
34
+ criterion_domain: :String,
35
+ start_date: :Date,
36
+ end_date: :Date,
37
+ source_value: :String,
38
+ source_vocabulary_id: :String,
39
+ label: :String,
40
+ column_family: :String
41
+ }.freeze
42
+
43
+ ADDITIONAL_COLUMNS = {
44
+ value_as_number: :Float,
45
+ value_as_string: :String,
46
+ value_as_concept_id: :Bigint,
47
+ value_as_concept: :String,
48
+ unit_concept_id: :Bigint,
49
+ unit_concept: :String,
50
+ context_id: :Bigint,
51
+ visit_occurrence_id: :Bigint,
52
+ file_provenance_type: :Bigint,
53
+ code_provenance_type: :Bigint,
54
+ provider_id: :Bigint,
55
+ context_specialty_concept_id: :Bigint,
56
+ practitioner_specialty_concept_id: :Bigint,
57
+ role_type_concept_id: :Bigint,
58
+ visit_source_concept_id: :Bigint,
59
+ range_low: :Float,
60
+ range_high: :Float,
61
+ drug_name: :String,
62
+ drug_amount: :Float,
63
+ drug_amount_units: :String,
64
+ drug_strength_source_value: :String,
65
+ drug_days_supply: :Bigint,
66
+ drug_quantity: :Float,
67
+ admission_date: :Date,
68
+ discharge_date: :Date,
69
+ length_of_stay: :Bigint,
70
+ admission_source_value: :String,
71
+ admission_source_description: :String,
72
+ discharge_location_source_value: :String,
73
+ discharge_location_source_description: :String,
74
+ primary_concept_id: :Bigint,
75
+ primary_diagnosis_code: :String,
76
+ primary_diagnosis_vocabulary: :String,
77
+ uuid: :String,
78
+ window_id: :Bigint
79
+ }.freeze
80
+
81
+ COLUMN_TYPES = DEFAULT_COLUMNS.merge(ADDITIONAL_COLUMNS).freeze
82
+
83
+ # Marks the WITH entry of a list that the Database's share_utilization
84
+ # option keeps in a shared table (Behaviors::Utilizable#evaluate).
85
+ SHARED_TABLE = :conceptql_shared_table
86
+
14
87
  attr_accessor :person_ids
15
- attr :known_operators
16
- def initialize
88
+
89
+ attr_reader :known_operators, :recall_stack, :recall_dependencies, :annotation, :opts, :query_columns
90
+
91
+ def initialize(opts = {})
17
92
  @known_operators = {}
18
- @flagged = {}
93
+ @recall_dependencies = {}
94
+ @recall_stack = []
95
+ @label_cte_names = {}
96
+ @annotation = {}
97
+ @opts = opts.dup
98
+ @annotation[:errors] = @errors = {}
99
+ @annotation[:warnings] = @warnings = {}
100
+ @annotation[:counts] = @counts = {}
101
+ @query_columns = DEFAULT_COLUMNS.keys
102
+ @query_columns << :window_id if opts.dig(:window_opts, :window_table)
103
+
104
+ @i = 0
105
+ @mutex = Mutex.new
106
+ @cte_name_next = -> { @mutex.synchronize { @i += 1 } }
107
+
108
+ return unless force_temp_tables? && scratch_database.to_s.empty?
109
+
110
+ raise ArgumentError,
111
+ "You must set the DOCKER_SCRATCH_DATABASE environment variable to the name of the scratch database if using the CONCEPTQL_FORCE_TEMP_TABLES environment variable"
19
112
  end
20
113
 
21
- def add_operator(operator)
22
- # Recall operators respond to source, so when we add such an
23
- # operator, we want to flag it so that we can come back to it later
24
- # and create the appropriate temp tables
25
- if operator.respond_to?(:source)
26
- flagged[operator.source] = true
114
+ def add_errors(key, errors)
115
+ @errors[key] = errors
116
+ end
117
+
118
+ def add_warnings(key, errors)
119
+ @warnings[key] = errors
120
+ end
121
+
122
+ def add_counts(key, domain, counts)
123
+ c = @counts[key] ||= {}
124
+ c[domain] = counts
125
+ end
126
+
127
+ def add_required_columns(op)
128
+ @query_columns |= op.required_columns if op.required_columns
129
+ end
130
+
131
+ def harvest_person_ids(op)
132
+ if (person_ids = op.options[:person_ids])
133
+ opts[:person_ids] = person_ids
134
+ end
135
+ end
136
+
137
+ def nest(op)
138
+ add_required_columns(op)
139
+ harvest_person_ids(op)
140
+ return yield unless (label = op.is_a?(Operators::Recall) ? op.source : op.label)
141
+
142
+ unless label.is_a?(String)
143
+ op.instance_eval do
144
+ @errors = []
145
+ add_error("invalid label")
146
+ end
147
+ return
27
148
  end
28
- return unless operator.label
149
+
150
+ recall_dependencies[label] ||= []
151
+
152
+ if nested_recall?(label)
153
+ op.instance_eval do
154
+ @errors = []
155
+ add_error("nested recall")
156
+ end
157
+ return
158
+ end
159
+
160
+ if duplicate_label?(label) && !op.is_a?(Operators::Recall)
161
+ op.instance_eval do
162
+ @errors = []
163
+ add_error("duplicate label")
164
+ end
165
+ end
166
+
167
+ if (last = recall_stack.last)
168
+ recall_dependencies[last] << label
169
+ end
170
+
171
+ begin
172
+ recall_stack.push(label)
173
+ yield
174
+ ensure
175
+ recall_stack.pop if recall_stack.last == label
176
+ end
177
+ end
178
+
179
+ def add_operator(operator)
29
180
  known_operators[operator.label] = operator
30
181
  end
31
182
 
183
+ def duplicate_label?(label)
184
+ known_operators.keys.map(&:downcase).include?(label.downcase)
185
+ end
186
+
187
+ def nested_recall?(label)
188
+ recall_stack.map(&:downcase).include?(label.downcase)
189
+ end
190
+
32
191
  def from(db, label)
33
- temp_table(db, label).from(db)
192
+ if ConceptQL.avoid_ctes?
193
+ ds = db.from(fetch_operator(label).evaluate(db).as(label_cte_name(label)))
194
+ else
195
+ ds = db.from(label_cte_name(label))
196
+
197
+ if ENV["CONCEPTQL_CHECK_COLUMNS"]
198
+ # Work around requests for columns by operators. These
199
+ # would fail because the CTE would not be defined. You
200
+ # don't want to define the CTE normally, but to allow the
201
+ # columns to still work, send the columns request to the
202
+ # underlying operator.
203
+ op = fetch_operator(label)
204
+ ds = ds.with_extend do
205
+ define_method(:columns) do
206
+ (@main_op ||= op.evaluate(db)).columns
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ ds
34
213
  end
35
214
 
36
- def types(label)
37
- fetch_operator(label).types
215
+ def domains(label, db)
216
+ fetch_operator(label).domains(db)
217
+ rescue StandardError
218
+ [ :invalid ]
38
219
  end
39
220
 
40
- def sql(db)
41
- temp_tables(db).values.map do |temp|
42
- temp.sql(db)
221
+ def sort_ctes(sorted, unsorted, deps)
222
+ return sorted if unsorted.empty?
223
+
224
+ add, unsorted = unsorted.partition do |label, _|
225
+ deps[label].empty?
43
226
  end
227
+
228
+ sorted += add
229
+
230
+ new_deps = {}
231
+ deps.map do |label, dps|
232
+ new_deps[label] = dps - sorted.map(&:first)
233
+ end
234
+
235
+ sort_ctes(sorted, unsorted, new_deps)
44
236
  end
45
237
 
46
- def prep(db)
47
- temp_tables(db).values.map do |temp|
48
- temp.build(db)
238
+ def valid?
239
+ recall_dependencies.each_value do |deps|
240
+ return false unless (deps - known_operators.keys).empty?
49
241
  end
242
+ true
50
243
  end
51
244
 
52
- private
53
- attr :flagged
245
+ class CteExtractor < Sequel::ASTTransformer
246
+ def initialize(scope, ctes)
247
+ super()
248
+ @scope = scope
249
+ @ctes = ctes
250
+ end
251
+
252
+ private
54
253
 
55
- def temp_tables(db)
56
- Hash[flagged.keys.map do |label|
57
- [label, TempTable.new(fetch_operator(label), db)]
58
- end]
254
+ def v(o)
255
+ if o.is_a?(Sequel::Dataset)
256
+ @scope.recursive_extract_ctes(o, @ctes)
257
+ else
258
+ super
259
+ end
260
+ end
59
261
  end
60
262
 
61
- def temp_table(db, label)
62
- temp_tables(db)[label]
263
+ def recursive_extract_cte_expr(t, ctes)
264
+ case t
265
+ when Sequel::Dataset
266
+ recursive_extract_ctes(t, ctes)
267
+ when Sequel::SQL::AliasedExpression
268
+ if t.expression.is_a?(Sequel::Dataset)
269
+ Sequel.as(recursive_extract_ctes(t.expression, ctes), t.alias)
270
+ else
271
+ t
272
+ end
273
+ else
274
+ t
275
+ end
276
+ end
277
+
278
+ def recursive_extract_ctes(query, ctes)
279
+ # puts
280
+ # p [:rec, ctes, query.opts]
281
+
282
+ if (with = query.opts[:with])
283
+ keep, remove = with.partition { |w| w[:no_temp_table] }
284
+ ctes.concat(remove.map do |w|
285
+ opts = w.except(:name, :dataset, :no_temp_table)
286
+ # A shared table is created from its whole list, nested CTEs included.
287
+ ds = w[SHARED_TABLE] ? w[:dataset] : recursive_extract_ctes(w[:dataset], ctes)
288
+ [ w[:name], ds, opts ]
289
+ end)
290
+ # p [:rec_with, ctes.map(&:first), with]
291
+ query = query.clone(with: keep.empty? ? nil : keep)
292
+ end
293
+
294
+ if (from = query.opts[:from])
295
+ query = query.clone(from: from.map { |t| recursive_extract_cte_expr(t, ctes) })
296
+ # p [:rec_from, ctes.map(&:first), from]
297
+ end
298
+
299
+ if (joins = query.opts[:join])
300
+ query = query.clone(join: joins.map do |jc|
301
+ table_expr = recursive_extract_cte_expr(jc.table_expr, ctes)
302
+ # A CROSS JOIN is a plain JoinClause, with no ON condition.
303
+ if jc.respond_to?(:on)
304
+ jc.class.new(jc.on, jc.join_type, table_expr)
305
+ else
306
+ jc.class.new(jc.join_type, table_expr)
307
+ end
308
+ end)
309
+ # p [:rec_join, ctes.map(&:first), joins]
310
+ end
311
+
312
+ if (compounds = query.opts[:compounds])
313
+ query = query.clone(compounds: compounds.map { |t, ds, a| [ t, recursive_extract_ctes(ds, ctes), a ] })
314
+ # p [:rec_compounds, ctes.map(&:first), compounds]
315
+ end
316
+
317
+ if (where = query.opts[:where])
318
+ query = query.clone(where: CteExtractor.new(self, ctes).transform(where))
319
+ end
320
+
321
+ query
322
+ end
323
+
324
+ # Removes duplicate CTE names, then moves each CTE after the CTEs it reads.
325
+ #
326
+ # A CTE reads another when its SQL contains the other's name as the
327
+ # adapter quotes it (ds.db.literal): "name" on Postgres and DuckDB,
328
+ # `name` on Spark, and "scratch"."name" for a temp table in a scratch
329
+ # database. The closing quote stops a name from matching the start of a
330
+ # longer name. Each name also ends with the scope's counter and 32 random
331
+ # hex digits, so a name does not appear in other SQL by accident.
332
+ #
333
+ # The sort is stable: each step takes the first CTE whose dependencies are
334
+ # already placed. recursive_extract_ctes appends the CTEs that a dataset
335
+ # reads before the dataset itself, so an order that is already correct
336
+ # does not change. (sort_ctes sorts by layers, and would move CTEs.)
337
+ def sort_extracted_ctes(ctes)
338
+ unsorted = ctes.uniq(&:first)
339
+ literals = unsorted.to_h { |name, ds| [ name, ds.db.literal(name) ] }
340
+ deps = unsorted.to_h do |name, ds|
341
+ sql = ds.sql
342
+ [ name, literals.filter_map { |other, literal| other if other != name && sql.include?(literal) } ]
343
+ end
344
+
345
+ sorted = []
346
+ until unsorted.empty?
347
+ placed = sorted.map(&:first)
348
+ index = unsorted.index { |name, _| (deps[name] - placed).empty? }
349
+ raise(CteCycleError, unsorted.map { |name, _| literals[name] }) unless index
350
+
351
+ sorted << unsorted.delete_at(index)
352
+ end
353
+ sorted
354
+ end
355
+
356
+ def with_ctes(op, db, options = {})
357
+ raise "recall operator use without matching label" unless valid?
358
+
359
+ query = op.evaluate(db)
360
+ rdbms = op.rdbms
361
+
362
+ query = query.from_self
363
+ temp_tables = []
364
+ ctes.each do |label, operator|
365
+ temp_tables << [ label_cte_name(label), recursive_extract_ctes(operator.evaluate(db), temp_tables) ]
366
+ end
367
+ query = recursive_extract_ctes(query, temp_tables)
368
+ shared_tables, temp_tables = temp_tables.partition { |_, _, with_opts| with_opts&.fetch(SHARED_TABLE, false) }
369
+ shared_tables = shared_tables.uniq(&:first)
370
+ temp_tables = sort_extracted_ctes(temp_tables)
371
+
372
+ if force_temp_tables?(options)
373
+ scope = self
374
+ query = query.with_extend do
375
+ # Create temp tables for each CTE
376
+ #
377
+ # Need to override multiple methods if sequel_pg is in use, as in
378
+ # that case not every method calls each. For other adapters or
379
+ # when sequel_pg is not in use, it is probably safe to override just each.
380
+ # There are other methods that may need to be overridden in order to handle
381
+ # all cases when sequel_pg is in use.
382
+ %i[each to_hash_groups to_hash].each do |meth|
383
+ define_method(meth) do |*args, &block|
384
+ if !temp_tables.empty? && !opts[:conceptql_temp_tables_created]
385
+ begin
386
+ temp_tables.each do |table_name, ds, _with_opts|
387
+ db.create_table(table_name, rdbms.create_options(scope, ds).merge(options).merge(as: ds))
388
+ rdbms.post_create(db, table_name)
389
+ end
390
+
391
+ clone(conceptql_temp_tables_created: true).send(meth, *args, &block)
392
+ ensure
393
+ drop_temp_tables
394
+ end
395
+ else
396
+ super(*args, &block)
397
+ end
398
+ end
399
+ end
400
+
401
+ define_method(:columns) do
402
+ if self.db.respond_to?(:record_table)
403
+ temp_tables.each do |table_name, ds, _with_opts|
404
+ self.db.record_table(table_name, self.db.columns_from_sql(ds))
405
+ end
406
+ end
407
+ super()
408
+ end
409
+
410
+ define_method(:sql_statements) do |*args, &block|
411
+ sql_statements = temp_tables.map do |table_name, ds|
412
+ [ table_name, ds.sql ]
413
+ end.compact.push([ :query, sql(*args, &block) ])
414
+ Hash[scope.shared_table_statements(shared_tables, op) + sql_statements]
415
+ end
416
+
417
+ define_method(:drop_temp_tables) do |opts = {}|
418
+ opts ||= {}
419
+ table_names = temp_tables.reverse_each.map(&:first)
420
+ begin
421
+ db.drop_table?(*table_names, rdbms.drop_options.merge(opts))
422
+ rescue Sequel::DatabaseError
423
+ warn("Unable to drop scratch table: #{literal(*table_names)}")
424
+ end
425
+ end
426
+ end
427
+ else
428
+ unless ConceptQL.avoid_ctes?
429
+ temp_tables.each do |table_name, ds, with_opts|
430
+ query = if with_opts.nil? || with_opts.empty?
431
+ query.with(table_name, ds)
432
+ else
433
+ query.with(table_name, ds, with_opts)
434
+ end
435
+ end
436
+ end
437
+
438
+ scope = self
439
+ query = query.with_extend do
440
+ define_method(:sql_statements) do |*args, &block|
441
+ Hash[scope.shared_table_statements(shared_tables, op).push([ :query, sql(*args, &block) ])]
442
+ end
443
+
444
+ define_method(:drop_temp_tables) do |opts = {}|
445
+ end
446
+ end
447
+ end
448
+
449
+ shared_tables.empty? ? query : create_shared_tables_on_read(query, shared_tables, op)
450
+ end
451
+
452
+ # The sql_statements entries that create the shared tables that do not
453
+ # exist yet. They come first, because the other entries read them.
454
+ def shared_table_statements(shared_tables, op)
455
+ shared_tables.filter_map { |name, key, _with_opts| op.nodifier.cdb.shared_utilization_statement(name, key) }
456
+ end
457
+
458
+ # Creates the shared tables that do not exist yet before the query reads
459
+ # its first rows, for a caller that reads rows through conceptql. This
460
+ # hook runs before the force_temp_tables hook, so the temp tables can read
461
+ # them. The shared tables are never in drop_temp_tables.
462
+ def create_shared_tables_on_read(query, shared_tables, op)
463
+ scope = self
464
+ cdb = op.nodifier.cdb
465
+ rdbms = op.rdbms
466
+ query.with_extend do
467
+ %i[each to_hash_groups to_hash].each do |meth|
468
+ define_method(meth) do |*args, &block|
469
+ shared_tables.each do |name, key, _with_opts|
470
+ cdb.create_shared_utilization_table(name, key, rdbms, scope)
471
+ end
472
+ super(*args, &block)
473
+ end
474
+ end
475
+ end
476
+ end
477
+
478
+ def label_cte_name(label)
479
+ @label_cte_names[label] ||= cte_name(label)
480
+ end
481
+
482
+ def ctes
483
+ @ctes ||= sort_ctes([], known_operators, recall_dependencies)
63
484
  end
64
485
 
65
486
  def fetch_operator(label)
66
- known_operators[label] || raise("No operator with label: '#{label}'")
487
+ known_operators[label]
488
+ end
489
+
490
+ def windows
491
+ @windows ||= Window.from(opts.merge(opts[:window_opts] || {}))
492
+ end
493
+
494
+ def force_temp_tables?(options = {})
495
+ # opts.fetch(:force_temp_tables, opts[:force_temp_tables])
496
+ options.fetch(:force_temp_tables, opts[:force_temp_tables])
497
+ end
498
+
499
+ def scratch_database
500
+ opts[:scratch_database]
501
+ end
502
+
503
+ def cte_name(name, options = {})
504
+ cte_parts = [
505
+ opts[:table_prefix],
506
+ name.to_s.gsub(/\W+/, "_"),
507
+ $PROCESS_ID,
508
+ @cte_name_next.call,
509
+ SecureRandom.hex(16)
510
+ ].compact
511
+
512
+ name = Sequel.identifier(cte_parts.join("_"))
513
+
514
+ name = name.qualify(scratch_database) if force_temp_tables?(options) && scratch_database
515
+
516
+ name
67
517
  end
68
518
  end
69
519
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module SqlFormatters
5
+ # An external SQL formatter, given as a command array that reads SQL on
6
+ # stdin, e.g. Formatter.new(%w[sql-formatter -l postgresql]).
7
+ class Formatter
8
+ attr_reader :command
9
+
10
+ def initialize(command)
11
+ @command = command
12
+ end
13
+
14
+ def available?
15
+ system("which", command.first, out: File::NULL, err: File::NULL)
16
+ end
17
+
18
+ def format(sql)
19
+ ConceptQL::Utils.timed_capture(*command, stdin_data: sql, timeout: 10)
20
+ rescue Timeout::Error
21
+ sql
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sql_formatters/formatter"
4
+
5
+ module ConceptQL
6
+ module SqlFormatters
7
+ SQL_FORMATTER = %w[sql-formatter].freeze
8
+ POSTGRES = %w[sql-formatter -l postgresql].freeze
9
+ SQLFORMAT = %w[sqlformat --reindent --use_space_around_operators --identifiers lower --keywords upper -].freeze
10
+ PG_FORMAT = %w[pg_format].freeze
11
+
12
+ # Formats with the rdbms's preferred formatter, else the first of the
13
+ # fallbacks that is installed, else returns the SQL untouched.
14
+ def self.format(sql, rdbms)
15
+ formatter = [ rdbms.preferred_formatter, SQL_FORMATTER, SQLFORMAT, PG_FORMAT ]
16
+ .compact
17
+ .map { |command| Formatter.new(command) }
18
+ .detect(&:available?)
19
+ formatter ? formatter.format(sql) : sql
20
+ end
21
+ end
22
+ end