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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +498 -0
- data/config/vocabularies.csv +85 -87
- data/config/vocabulary_formats.sha +1 -0
- data/config/vocabulary_formats.tsv +39 -0
- data/doc/spec.md +2091 -0
- data/lib/conceptql/behaviors/metadatable.rb +15 -3
- data/lib/conceptql/behaviors/provenanceable.rb +82 -8
- data/lib/conceptql/behaviors/utilizable.rb +5 -3
- data/lib/conceptql/cli.rb +1 -1
- data/lib/conceptql/data_model/base.rb +3 -171
- data/lib/conceptql/data_model/gdm.rb +3 -1
- data/lib/conceptql/data_model.rb +20 -4
- data/lib/conceptql/database.rb +9 -2
- data/lib/conceptql/date_adjuster.rb +12 -5
- data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
- data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
- data/lib/conceptql/lexicon/lexicon_strategy.rb +58 -4
- data/lib/conceptql/lexicon.rb +218 -2
- data/lib/conceptql/nodifier.rb +7 -1
- data/lib/conceptql/one_query_counts.rb +120 -0
- data/lib/conceptql/operators/co_reported.rb +4 -31
- data/lib/conceptql/operators/count.rb +4 -0
- data/lib/conceptql/operators/death.rb +1 -1
- data/lib/conceptql/operators/dedup.rb +1 -1
- data/lib/conceptql/operators/episode.rb +10 -2
- data/lib/conceptql/operators/ethnicity.rb +0 -4
- data/lib/conceptql/operators/except.rb +5 -0
- data/lib/conceptql/operators/filter.rb +12 -2
- data/lib/conceptql/operators/hospice.rb +2 -3
- data/lib/conceptql/operators/hospitalization.rb +2 -3
- data/lib/conceptql/operators/information_periods.rb +5 -4
- data/lib/conceptql/operators/invalid.rb +7 -1
- data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
- data/lib/conceptql/operators/numeric.rb +51 -14
- data/lib/conceptql/operators/occurrence.rb +16 -4
- data/lib/conceptql/operators/one_in_two_out.rb +1 -2
- data/lib/conceptql/operators/operator.rb +66 -30
- data/lib/conceptql/operators/person.rb +1 -1
- data/lib/conceptql/operators/person_filter.rb +5 -0
- data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
- data/lib/conceptql/operators/provenance.rb +13 -4
- data/lib/conceptql/operators/provider_filter.rb +5 -1
- data/lib/conceptql/operators/race.rb +2 -10
- data/lib/conceptql/operators/read.rb +1 -100
- data/lib/conceptql/operators/recall.rb +1 -1
- data/lib/conceptql/operators/snf.rb +3 -4
- data/lib/conceptql/operators/sum.rb +46 -5
- data/lib/conceptql/operators/temporal_operator.rb +6 -2
- data/lib/conceptql/operators/trim_date.rb +52 -6
- data/lib/conceptql/operators/trim_date_end.rb +11 -1
- data/lib/conceptql/operators/trim_date_start.rb +11 -1
- data/lib/conceptql/operators/union.rb +45 -1
- data/lib/conceptql/operators/vocabulary.rb +21 -4
- data/lib/conceptql/paths.rb +16 -0
- data/lib/conceptql/query.rb +78 -24
- data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
- data/lib/conceptql/rdbms/generic.rb +21 -1
- data/lib/conceptql/rdbms/spark.rb +12 -0
- data/lib/conceptql/scope.rb +187 -24
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
- data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
- data/lib/conceptql/vocabularies/entry.rb +15 -23
- data/lib/conceptql/vocabularies/formats.rb +59 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +8 -16
- data/lib/conceptql/window/table.rb +12 -3
- metadata +9 -14
- data/lib/conceptql/data_model/omopv4_plus.rb +0 -12
- data/lib/conceptql/operators/concurrent_within.rb +0 -69
- data/lib/conceptql/query_modifiers/omopv4_plus/admission_date_query_modifier.rb +0 -45
- data/lib/conceptql/query_modifiers/omopv4_plus/drug_query_modifier.rb +0 -80
- data/lib/conceptql/query_modifiers/omopv4_plus/pos_query_modifier.rb +0 -47
- data/lib/conceptql/query_modifiers/omopv4_plus/provenance_query_modifier.rb +0 -45
- data/lib/conceptql/query_modifiers/omopv4_plus/provider_query_modifier.rb +0 -26
- data/lib/conceptql/vocabularies/behaviors/costish.rb +0 -24
- data/lib/conceptql/vocabularies/behaviors/omopish.rb +0 -32
- data/schemas/omopv4_plus.yml +0 -729
- 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
|
-
|
|
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 "
|
|
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
|
-
|
|
9
|
-
register __FILE__, :gdm, :gdm_wide
|
|
8
|
+
register __FILE__
|
|
10
9
|
include ConceptQL::Behaviors::Utilizable
|
|
11
10
|
|
|
12
|
-
desc "Selects
|
|
11
|
+
desc "Selects skilled nursing facility (SNF) admission records."
|
|
13
12
|
|
|
14
13
|
def collection_type
|
|
15
|
-
"
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
43
|
+
warn_about_reused_codes(db, args - missing_args)
|
|
44
|
+
end
|
|
43
45
|
|
|
44
|
-
|
|
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 (
|
|
128
|
-
@code_regexp = Regexp.union(
|
|
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
|
data/lib/conceptql/paths.rb
CHANGED
|
@@ -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
|
data/lib/conceptql/query.rb
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
+
private
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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}"
|