conceptql 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +498 -0
  3. data/config/vocabularies.csv +85 -87
  4. data/config/vocabulary_formats.sha +1 -0
  5. data/config/vocabulary_formats.tsv +39 -0
  6. data/doc/spec.md +2091 -0
  7. data/lib/conceptql/behaviors/metadatable.rb +15 -3
  8. data/lib/conceptql/behaviors/provenanceable.rb +82 -8
  9. data/lib/conceptql/behaviors/utilizable.rb +5 -3
  10. data/lib/conceptql/cli.rb +1 -1
  11. data/lib/conceptql/data_model/base.rb +3 -171
  12. data/lib/conceptql/data_model/gdm.rb +3 -1
  13. data/lib/conceptql/data_model.rb +20 -4
  14. data/lib/conceptql/database.rb +9 -2
  15. data/lib/conceptql/date_adjuster.rb +12 -5
  16. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  17. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  18. data/lib/conceptql/lexicon/lexicon_strategy.rb +58 -4
  19. data/lib/conceptql/lexicon.rb +218 -2
  20. data/lib/conceptql/nodifier.rb +7 -1
  21. data/lib/conceptql/one_query_counts.rb +120 -0
  22. data/lib/conceptql/operators/co_reported.rb +4 -31
  23. data/lib/conceptql/operators/count.rb +4 -0
  24. data/lib/conceptql/operators/death.rb +1 -1
  25. data/lib/conceptql/operators/dedup.rb +1 -1
  26. data/lib/conceptql/operators/episode.rb +10 -2
  27. data/lib/conceptql/operators/ethnicity.rb +0 -4
  28. data/lib/conceptql/operators/except.rb +5 -0
  29. data/lib/conceptql/operators/filter.rb +12 -2
  30. data/lib/conceptql/operators/hospice.rb +2 -3
  31. data/lib/conceptql/operators/hospitalization.rb +2 -3
  32. data/lib/conceptql/operators/information_periods.rb +5 -4
  33. data/lib/conceptql/operators/invalid.rb +7 -1
  34. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  35. data/lib/conceptql/operators/numeric.rb +51 -14
  36. data/lib/conceptql/operators/occurrence.rb +16 -4
  37. data/lib/conceptql/operators/one_in_two_out.rb +1 -2
  38. data/lib/conceptql/operators/operator.rb +66 -30
  39. data/lib/conceptql/operators/person.rb +1 -1
  40. data/lib/conceptql/operators/person_filter.rb +5 -0
  41. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  42. data/lib/conceptql/operators/provenance.rb +13 -4
  43. data/lib/conceptql/operators/provider_filter.rb +5 -1
  44. data/lib/conceptql/operators/race.rb +2 -10
  45. data/lib/conceptql/operators/read.rb +1 -100
  46. data/lib/conceptql/operators/recall.rb +1 -1
  47. data/lib/conceptql/operators/snf.rb +3 -4
  48. data/lib/conceptql/operators/sum.rb +46 -5
  49. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  50. data/lib/conceptql/operators/trim_date.rb +52 -6
  51. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  52. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  53. data/lib/conceptql/operators/union.rb +45 -1
  54. data/lib/conceptql/operators/vocabulary.rb +21 -4
  55. data/lib/conceptql/paths.rb +16 -0
  56. data/lib/conceptql/query.rb +78 -24
  57. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  58. data/lib/conceptql/rdbms/generic.rb +21 -1
  59. data/lib/conceptql/rdbms/spark.rb +12 -0
  60. data/lib/conceptql/scope.rb +187 -24
  61. data/lib/conceptql/version.rb +1 -1
  62. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  63. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  64. data/lib/conceptql/vocabularies/entry.rb +15 -23
  65. data/lib/conceptql/vocabularies/formats.rb +59 -0
  66. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
  67. data/lib/conceptql/window/table.rb +12 -3
  68. metadata +9 -14
  69. data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
  70. data/lib/conceptql/operators/concurrent_within.rb +0 -69
  71. data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
  72. data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
  73. data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
  74. data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
  75. data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
  76. data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
  77. data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
  78. data/schemas/omopv4_plus.yml +0 -729
  79. data/schemas/omopv4_plus_more_ignorables.tsv +0 -13
@@ -18,25 +18,15 @@ module ConceptQL
18
18
  conceptql_spec_id "vocabulary"
19
19
 
20
20
  def query(db)
21
- gdm? ? gdm(db) : omopv4(db)
22
- end
23
-
24
- def gdm(db)
25
21
  ops(db).first.evaluate(db)
26
22
  end
27
23
 
28
- def omopv4(db)
29
- streams = ops(db).map { |op| op.evaluate(db) }
30
-
31
- streams.inject { |q, query| q.union(query, all: true) }.from_self
32
- end
33
-
34
24
  def domains(db)
35
25
  ops.first.domains(db)
36
26
  end
37
27
 
38
28
  def query_cols
39
- gdm? ? ops.first.query_cols : ops.map(&:query_cols).flatten.uniq
29
+ ops.first.query_cols
40
30
  end
41
31
 
42
32
  def required_columns
@@ -53,40 +43,11 @@ module ConceptQL
53
43
  ops.flat_map { |op| op.describe_codes(db, codes) }
54
44
  end
55
45
 
56
- def klasses
57
- @klasses ||= {
58
- condition_occurrence: ReadCondition,
59
- procedure_occurrence: ReadProcedure,
60
- drug_exposure: ReadDrug,
61
- observation: ReadObservation
62
- }
63
- end
64
-
65
46
  class ReadBase < ConceptQL::Operators::Vocabulary
66
47
  preferred_name "READ"
67
48
  argument :read_codes, type: :codelist, vocab: "Read"
68
49
  end
69
50
 
70
- class ReadOmopBase < ReadBase
71
- include ConceptQL::Vocabularies::Behaviors::Omopish
72
-
73
- def vocabulary_id
74
- 17
75
- end
76
-
77
- def source_table
78
- table
79
- end
80
-
81
- def domain
82
- table
83
- end
84
-
85
- def domains(_db)
86
- Array(domain)
87
- end
88
- end
89
-
90
51
  class ReadGDM < ReadBase
91
52
  include ConceptQL::Vocabularies::Behaviors::Gdmish
92
53
  include ConceptQL::Behaviors::Labish
@@ -103,66 +64,6 @@ module ConceptQL
103
64
  [ :condition_occurrence ]
104
65
  end
105
66
  end
106
-
107
- class ReadCondition < ReadOmopBase
108
- def table
109
- :procedure_occurrence
110
- end
111
-
112
- def source_column
113
- :procedure_source_value
114
- end
115
-
116
- def concept_column
117
- :procedure_concept_id
118
- end
119
- end
120
-
121
- class ReadProcedure < ReadOmopBase
122
- def table
123
- :procedure_occurrence
124
- end
125
-
126
- def source_column
127
- :procedure_source_value
128
- end
129
-
130
- def concept_column
131
- :procedure_concept_id
132
- end
133
- end
134
-
135
- class ReadObservation < ReadOmopBase
136
- include ConceptQL::Behaviors::Labish
137
-
138
- def table
139
- :observation
140
- end
141
-
142
- def source_column
143
- :observation_source_value
144
- end
145
-
146
- def concept_column
147
- :observation_concept_id
148
- end
149
- end
150
-
151
- class ReadDrug < ReadOmopBase
152
- include ConceptQL::Behaviors::Drugish
153
-
154
- def table
155
- :drug_exposure
156
- end
157
-
158
- def source_column
159
- :drug_source_value
160
- end
161
-
162
- def concept_column
163
- :drug_concept_id
164
- end
165
- end
166
67
  end
167
68
  end
168
69
  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"
@@ -5,14 +5,13 @@ require_relative "operator"
5
5
  module ConceptQL
6
6
  module Operators
7
7
  class Snf < Operator
8
- # GDM only: omopv4_plus has no provenance vocabularies (conceptql-myp).
9
- register __FILE__, :gdm, :gdm_wide
8
+ register __FILE__
10
9
  include ConceptQL::Behaviors::Utilizable
11
10
 
12
- desc "Selects admission records of type 'SNF'"
11
+ desc "Selects skilled nursing facility (SNF) admission records."
13
12
 
14
13
  def collection_type
15
- "SNF"
14
+ "snf"
16
15
  end
17
16
  end
18
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)
@@ -14,6 +14,18 @@ module ConceptQL
14
14
  #
15
15
  # If the RHS record's start_date is later than the LHS end_date, the LHS
16
16
  # record is passed thru unaffected.
17
+ #
18
+ # With join_columns (only t_shank gives it), "per person" means per
19
+ # person and join_columns values, with or without compare_all.
20
+ #
21
+ # With compare_all (only t_shank gives it), each LHS record is compared
22
+ # with every RHS record of the person and of the join_columns, not only
23
+ # with the person's first (trim_date_end) or last (trim_date_start) RHS
24
+ # record. Each LHS record comes out once, trimmed by the nearest RHS
25
+ # record that passes: the earliest start_date on or after its start_date
26
+ # (trim_date_end), or the latest end_date on or before its end_date
27
+ # (trim_date_start). An LHS record is discarded only when it has RHS
28
+ # records and none of them passes.
17
29
  class TrimDate < TemporalOperator
18
30
  desc <<~EOF
19
31
  Trims the end_date of the left hand records (LHR) by the earliest
@@ -38,6 +50,8 @@ module ConceptQL
38
50
  .left_join(Sequel.as(right_stream_query(db), :r), join_columns.inject(&:&))
39
51
  .where(where_criteria)
40
52
 
53
+ ds = nearest_trim_date(ds) if compare_all?
54
+
41
55
  ds = dm.selectify(ds, qualifier: :l, replace: replacement_columns)
42
56
 
43
57
  ds = apply_selectors(ds)
@@ -51,16 +65,48 @@ module ConceptQL
51
65
  options[:compare_all]
52
66
  end
53
67
 
68
+ # With compare_all, each left row joins every right row of the person
69
+ # (and of the join columns), and the where clause keeps the right rows
70
+ # that pass. Group those back to one row for each left row, and keep
71
+ # the nearest passing trim date (trim_function). A left row without a
72
+ # right row has a NULL trim date, so it passes through unchanged (bead
73
+ # conceptql-x6m, seed conceptql-ysx). Every stream is a set, so left
74
+ # rows that are equal in every column come out once (seed conceptql-0eb).
75
+ def nearest_trim_date(ds)
76
+ ds.select_group(*dynamic_columns.map { |column| Sequel[:l][column] })
77
+ .select_append(Sequel.function(trim_function, trim_bound).as(:trim_bound))
78
+ .from_self(alias: :l)
79
+ end
80
+
81
+ # The trim date of the joined right row, or the nearest one of the left
82
+ # row when compare_all grouped the right rows.
83
+ def trim_date_column
84
+ compare_all? ? Sequel[:l][:trim_bound] : trim_bound
85
+ end
86
+
54
87
  def right_stream_query(db)
55
- rh = if compare_all?
56
- super
57
- else
58
- occ = to_op([ :occurrence, occurrence_number, right ])
59
- occ.evaluate(db)
60
- end
88
+ rh = compare_all? ? super : nearest_right_row(db)
61
89
  columnizer.apply(rh)
62
90
  end
63
91
 
92
+ # Without compare_all, the first (trim_date_end) or last
93
+ # (trim_date_start) right row of each person and join_columns values,
94
+ # the grouping compare_all uses. The rows are ordered as the occurrence
95
+ # operator orders them: by start_date, then criterion_id. The occurrence
96
+ # operator groups by person only, so a left row whose join values
97
+ # differed from those of the person's first right row passed through
98
+ # untrimmed (bead conceptql-18n).
99
+ def nearest_right_row(db)
100
+ direction = occurrence_number.negative? ? :desc : :asc
101
+ right.evaluate(db)
102
+ .select_append(Sequel[:ROW_NUMBER].function.over(
103
+ partition: join_columns_option.uniq,
104
+ order: %i[start_date criterion_id].map { |column| Sequel.send(direction, column) }
105
+ ).as(:rn))
106
+ .from_self
107
+ .where(rn: occurrence_number.abs)
108
+ end
109
+
64
110
  def columnizer
65
111
  columnizer = Columnizer.new
66
112
  columnizer.add_columns(:person_id, trim_date, *join_columns_option)
@@ -32,7 +32,17 @@ module ConceptQL
32
32
  end
33
33
 
34
34
  def replacement_columns
35
- { end_date: Sequel.function(:least, l_end_date, Sequel.function(:coalesce, within_start, l_end_date)) }
35
+ { end_date: Sequel.function(:least, l_end_date, Sequel.function(:coalesce, trim_date_column, l_end_date)) }
36
+ end
37
+
38
+ # The date that trims a left row, from one right row.
39
+ def trim_bound
40
+ within_start
41
+ end
42
+
43
+ # With compare_all, the nearest trim date of the right rows that pass.
44
+ def trim_function
45
+ :min
36
46
  end
37
47
 
38
48
  def occurrence_number
@@ -32,7 +32,17 @@ module ConceptQL
32
32
  end
33
33
 
34
34
  def replacement_columns
35
- { start_date: Sequel.function(:greatest, l_start_date, Sequel.function(:coalesce, within_end, l_start_date)) }
35
+ { start_date: Sequel.function(:greatest, l_start_date, Sequel.function(:coalesce, trim_date_column, l_start_date)) }
36
+ end
37
+
38
+ # The date that trims a left row, from one right row.
39
+ def trim_bound
40
+ within_end
41
+ end
42
+
43
+ # With compare_all, the nearest trim date of the right rows that pass.
44
+ def trim_function
45
+ :max
36
46
  end
37
47
 
38
48
  def occurrence_number
@@ -70,11 +70,55 @@ module ConceptQL
70
70
 
71
71
  queries += Combiner.new(combinables).queries(db)
72
72
 
73
+ all = disjoint_upstreams?
73
74
  queries.map! { |ds| ds.from_self.select(*query_cols).from_self }
74
75
  queries.inject do |q, query|
75
- q.union(query, all: true)
76
+ q.union(query, all: all)
76
77
  end
77
78
  end
79
+
80
+ # The (criterion_domain, vocabulary condition) pairs of every row this
81
+ # union can emit, or nil when an upstream does not fix them at build
82
+ # time. See #disjoint_upstreams?.
83
+ def branches
84
+ upstream_branches = upstreams.map { |upstream| branches_of(upstream) }
85
+ upstream_branches.flatten(1) if upstream_branches.all?
86
+ end
87
+
88
+ private
89
+
90
+ # Every stream is a set (seed conceptql-0eb), so union removes the rows
91
+ # that arrive from more than one upstream: UNION, not UNION ALL. The
92
+ # duplicate removal is expensive on large data (conceptql-5pz), so union
93
+ # skips it when no row can arrive from two of its queries. It proves
94
+ # that only for vocabulary operators, cpt_or_hcpcs-style operators and
95
+ # unions of them, because each of their rows carries the operator's
96
+ # domain as criterion_domain and comes from a row of its vocabulary.
97
+ # Two rows with different domains differ, and so do two rows of one
98
+ # domain (one table) from different vocabularies of one column. Any
99
+ # other upstream can overlap anything, so union removes duplicates.
100
+ def disjoint_upstreams?
101
+ combinables, individuals = upstreams.partition { |upstream| upstream.is_a?(Vocabulary) }
102
+ queries = individuals.map { |upstream| branches_of(upstream) }
103
+ queries += combinables.group_by(&:domain).values.map { |combos| combos.flat_map { |c| branches_of(c) } }
104
+ return false unless queries.all?
105
+
106
+ queries.combination(2).all? do |one, other|
107
+ one.product(other).all? { |a, b| disjoint_branches?(a, b) }
108
+ end
109
+ end
110
+
111
+ def branches_of(upstream)
112
+ case upstream
113
+ when Vocabulary then [ [ upstream.domain, upstream.vocabulary_condition ] ]
114
+ when MultipleVocabularies then upstream.vocab_ops.flat_map { |vo| branches_of(vo) }
115
+ when Union then upstream.branches
116
+ end
117
+ end
118
+
119
+ def disjoint_branches?((domain, condition), (other_domain, other_condition))
120
+ domain != other_domain || (condition.keys == other_condition.keys && condition != other_condition)
121
+ end
78
122
  end
79
123
  end
80
124
  end
@@ -38,10 +38,23 @@ module ConceptQL
38
38
  args -= bad_arguments
39
39
 
40
40
  missing_args = args - dm.known_codes(db, vocabulary_id, args)
41
+ add_warning(UNKNOWN_CODE, *missing_args) unless missing_args.empty?
41
42
 
42
- return if missing_args.empty?
43
+ warn_about_reused_codes(db, args - missing_args)
44
+ end
43
45
 
44
- add_warning("unknown code(s)", *missing_args)
46
+ # A code that the vocabulary reused for a new meaning selects records of
47
+ # both meanings: DRG 410 selects chemotherapy stays before 2007-10-01 and
48
+ # biliary tract procedures after it (conceptql-ttk). Each item names the
49
+ # code and all of its names, so the user sees the problem without a
50
+ # vocabulary lookup.
51
+ def warn_about_reused_codes(db, codes)
52
+ meanings = dm.code_meanings(db, vocabulary_id, codes)
53
+ reused = codes.filter_map do |code|
54
+ names = meanings[code.downcase]
55
+ "#{code}: #{names.join(' | ')}" if names
56
+ end
57
+ add_warning("code(s) with more than one meaning", *reused) unless reused.empty?
45
58
  end
46
59
 
47
60
  def describe_codes(db, codes)
@@ -124,13 +137,17 @@ module ConceptQL
124
137
  unless defined?(@code_regexp)
125
138
  @code_regexp = nil
126
139
 
127
- if (reg_str = vocab_entry.format_regexp)
128
- @code_regexp = Regexp.union(Regexp.new(reg_str, Regexp::IGNORECASE), /\A\*\Z/)
140
+ if (format = Vocabularies::Formats.regexp(vocab_entry.omopv5_id))
141
+ @code_regexp = Regexp.union(format, /\A\*\Z/)
129
142
  end
130
143
  end
131
144
  @code_regexp
132
145
  end
133
146
 
147
+ def bad_format_message
148
+ vocab_entry.omopv5_id == "NDC" ? NDC_BAD_FORMAT : BAD_FORMAT
149
+ end
150
+
134
151
  def vocabulary_id
135
152
  @vocabulary_id ||= translated_vocabulary_id
136
153
  end
@@ -22,6 +22,16 @@ module ConceptQL
22
22
  ConceptQL.config_dir / "vocabularies.csv"
23
23
  end
24
24
 
25
+ # A pinned copy of vocabulary_formats.tsv (ConceptQL::Vocabularies::Formats)
26
+ def vocabulary_formats_file_path
27
+ ConceptQL.config_dir / "vocabulary_formats.tsv"
28
+ end
29
+
30
+ # The vocabulary_formats commit that the copy came from
31
+ def vocabulary_formats_sha_file_path
32
+ ConceptQL.config_dir / "vocabulary_formats.sha"
33
+ end
34
+
25
35
  def multiple_vocabularies_file_path
26
36
  ConceptQL.config_dir / "multiple_vocabularies.csv"
27
37
  end
@@ -37,5 +47,11 @@ module ConceptQL
37
47
  def race_file
38
48
  ConceptQL.config_dir / "race.yml"
39
49
  end
50
+
51
+ # The ConceptQL specification, shipped in the gem for applications that
52
+ # serve it to readers (the Jigsaw diagram editor serves it over MCP).
53
+ def spec_path
54
+ root / "doc" / "spec.md"
55
+ end
40
56
  end
41
57
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "forwardable"
5
5
  require_relative "scope"
6
6
  require_relative "nodifier"
7
+ require_relative "one_query_counts"
7
8
  require "sequel/sqlformat"
8
9
 
9
10
  module ConceptQL
@@ -20,29 +21,39 @@ module ConceptQL
20
21
  [ super, "OG ERROR:", og_message ].join("\n")
21
22
  end
22
23
  end
24
+ end
23
25
 
24
- # Raised by #code_list when the statement has validation errors: the
25
- # operator tree of an invalid statement can have missing operands, and a
26
- # partial code list would look complete. #errors is the errors hash of
27
- # #scope_annotate (operator name => list of [message, *details]).
28
- class InvalidStatementError < QueryError
29
- attr_reader :statement, :errors
30
-
31
- def initialize(statement, errors)
32
- @statement = statement
33
- @errors = errors
34
- super([ "Invalid ConceptQL statement, so it has no code list. Validation errors:",
35
- *errors.flat_map { |op, op_errors| op_errors.map { |e| " #{op}: #{describe(e)}" } },
36
- "Statement: #{statement.inspect}" ].join("\n"))
37
- end
26
+ # Raised when a Query builds the SQL of a statement (#query, #sql,
27
+ # #sql_statements, and #all, #count, #execute, #order and #analyze through
28
+ # #query) or its code list (#code_list), and the statement has validation
29
+ # errors. An invalid statement can crash while it builds, or build SQL that
30
+ # silently returns fewer rows. Warnings do not raise.
31
+ #
32
+ # #errors is the errors hash that #scope_annotate reports (operator name =>
33
+ # list of [message, *details]), so it holds the errors that annotate shows.
34
+ # #statement is the statement that the Query holds.
35
+ class InvalidStatementError < Query::QueryError
36
+ attr_reader :statement, :errors
37
+
38
+ def initialize(statement, errors)
39
+ @statement = statement
40
+ @errors = errors
41
+ super([ "Invalid ConceptQL statement. Validation errors:",
42
+ *errors.flat_map { |op, op_errors| op_errors.map { |e| " #{op}: #{describe(e)}" } },
43
+ "Statement: #{statement.inspect}" ].join("\n"))
44
+ end
38
45
 
39
- private
46
+ private
40
47
 
41
- def describe(error)
42
- message, *details = error
43
- details.empty? ? message.to_s : "#{message} (#{details.flatten.map(&:to_s).join(', ')})"
44
- end
48
+ def describe(error)
49
+ message, *details = error
50
+ details.empty? ? message.to_s : "#{message} (#{details.flatten.map(&:to_s).join(', ')})"
45
51
  end
52
+ end
53
+
54
+ class Query
55
+ # The name of InvalidStatementError in 0.4.0 to 0.5.0.
56
+ InvalidStatementError = ConceptQL::InvalidStatementError
46
57
 
47
58
  extend Forwardable
48
59
  def_delegators :query, :all, :count, :execute, :order
@@ -66,7 +77,9 @@ module ConceptQL
66
77
  query(explain: true, analyze: true).analyze
67
78
  end
68
79
 
80
+ # Raises InvalidStatementError if the statement has validation errors.
69
81
  def query(opts = {})
82
+ validate!
70
83
  (@queries ||= {})[opts] ||= nodifier.scope.with_ctes(operator, db, opts)
71
84
  end
72
85
 
@@ -112,11 +125,26 @@ module ConceptQL
112
125
  end
113
126
  end
114
127
  Hash[stmts]
128
+ rescue InvalidStatementError
129
+ raise
115
130
  rescue StandardError
116
131
  raise QueryError, "Failed to generate SQL for #{statement.inspect}"
117
132
  end
118
133
 
134
+ # With counts on, DuckDB and Postgres count every operator with one SQL
135
+ # statement (OneQueryCounts). Operators memoize #annotate, so a later call
136
+ # with other opts returns the first result.
137
+ #
138
+ # The errors of the window table's own statement are reported under
139
+ # "window_table" (see #window_table_errors). Then nothing is counted,
140
+ # because every count joins the window table, and building its SQL raises.
119
141
  def annotate(opts = {})
142
+ window_errors = window_table_errors
143
+ unless window_errors.empty?
144
+ nodifier.scope.add_errors("window_table", window_errors)
145
+ opts = opts.merge(skip_counts: true)
146
+ end
147
+ nodifier.scope.one_query_counts = OneQueryCounts.new(operator, db, opts)
120
148
  operator.annotate(db, opts)
121
149
  end
122
150
 
@@ -164,12 +192,11 @@ module ConceptQL
164
192
  operator.rdbms
165
193
  end
166
194
 
167
- # Raises InvalidStatementError if the statement has validation errors.
168
- # Warnings do not raise.
195
+ # Raises InvalidStatementError if the statement has validation errors:
196
+ # the operator tree of an invalid statement can have missing operands,
197
+ # and a partial code list would look complete. Warnings do not raise.
169
198
  def code_list(_ignored_db = nil)
170
- errors = validation_errors
171
- raise InvalidStatementError.new(statement, errors) unless errors.empty?
172
-
199
+ validate!
173
200
  operator.code_list(db).uniq
174
201
  end
175
202
 
@@ -189,6 +216,33 @@ module ConceptQL
189
216
 
190
217
  attr_reader :cdb, :nodifier
191
218
 
219
+ # Raises InvalidStatementError if #validation_errors is not empty. The
220
+ # statement and the opts of a Query do not change, so it checks once.
221
+ # Warnings do not raise. cause: nil keeps an error that a caller is
222
+ # handling out of the message (QueryError#message prints the cause).
223
+ def validate!
224
+ return if @valid
225
+
226
+ errors = validation_errors
227
+ raise InvalidStatementError.new(statement, errors), cause: nil unless errors.empty?
228
+
229
+ @valid = true
230
+ end
231
+
232
+ # The validation errors of the window table's statement, when the window
233
+ # table is a ConceptQL statement (an Array, as Window::Table reads it), as
234
+ # one list of [message, *details]. Each message starts with the name of
235
+ # the operator that reported it. A table name has no errors.
236
+ def window_table_errors
237
+ scope_opts = nodifier.scope.opts
238
+ window_table = scope_opts.merge(scope_opts[:window_opts] || {})[:window_table]
239
+ return [] unless window_table.is_a?(Array)
240
+
241
+ cdb.query(window_table).validation_errors.flat_map do |op, op_errors|
242
+ op_errors.map { |message, *details| [ "#{op}: #{message}", *details ] }
243
+ end
244
+ end
245
+
192
246
  def extract_statement(stmt, opts)
193
247
  if !stmt.is_a?(Array)
194
248
  raise "Improper ConceptQL statement: Expected an Array, got a #{stmt.class}"