conceptql 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +454 -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 +207 -67
- data/lib/conceptql/behaviors/metadatable.rb +15 -3
- data/lib/conceptql/behaviors/provenanceable.rb +82 -8
- data/lib/conceptql/behaviors/utilizable.rb +5 -1
- data/lib/conceptql/data_model/base.rb +2 -4
- data/lib/conceptql/data_model/gdm.rb +3 -1
- data/lib/conceptql/database.rb +7 -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 +35 -1
- data/lib/conceptql/lexicon.rb +210 -2
- data/lib/conceptql/nodifier.rb +7 -1
- data/lib/conceptql/one_query_counts.rb +120 -0
- 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 +1 -1
- data/lib/conceptql/operators/hospitalization.rb +1 -1
- data/lib/conceptql/operators/information_periods.rb +4 -1
- 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/operator.rb +63 -19
- 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 +12 -2
- data/lib/conceptql/operators/provider_filter.rb +5 -1
- data/lib/conceptql/operators/race.rb +0 -4
- data/lib/conceptql/operators/recall.rb +1 -1
- data/lib/conceptql/operators/snf.rb +2 -2
- 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 +7 -3
- data/lib/conceptql/paths.rb +10 -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 -3
- data/lib/conceptql/vocabularies/formats.rb +59 -0
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
- data/lib/conceptql/window/table.rb +12 -3
- metadata +8 -4
- data/lib/conceptql/operators/concurrent_within.rb +0 -69
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03af33e5bd8d7515c78256e11c98fe9aab5bd2501f56fea7db2960acf55c3ea6
|
|
4
|
+
data.tar.gz: 27e02e2f1da7e8acfe3bda3a2a7787327f386716a1d8ddaa16ca371e2798bd1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 209474ec28b2fa409fa6d0bde640c411d6beaf5bff0c67c346483fc25b59e5465f2ca2fb444376397b1080d492d4a037d21314f0cd206837551807c016058748
|
|
7
|
+
data.tar.gz: 46dca2627d4085ee7c3ad42b821b03c685536cd933ea63987061cbad58bec28c01c07e9dcad48845fe66d5343265d289599f87ef2a6c67a92d61818624f446c3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,460 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-11-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `provenance` takes keywords of the form `<file keyword>:<code keyword>` again (the form
|
|
12
|
+
existed from 2019 until the lexicon rewrite of 2023). `"inpatient:primary_or_first"` keeps
|
|
13
|
+
the records whose file provenance is in the descendants of `inpatient` AND whose code
|
|
14
|
+
provenance is in the descendants of `primary_or_first`. A plain keyword keeps its meaning:
|
|
15
|
+
it keeps a record when either column is in the descendants of its concept. Several keywords
|
|
16
|
+
in one operator are still ORed, so `["inpatient:primary", "outpatient:first"]` keeps the
|
|
17
|
+
records of either pair. The left half must be a file provenance keyword and the right half
|
|
18
|
+
a code provenance keyword. Any other keyword with a `:` (a swapped pair, an unknown half, an
|
|
19
|
+
empty half or a second `:`) is the validation error `unrecognized keywords`, with the
|
|
20
|
+
keyword as written, and the plain query raises `ConceptQL::InvalidStatementError` for it.
|
|
21
|
+
Before, annotate reported every keyword with a `:` as that error. The test statement
|
|
22
|
+
`provenance/count_2.json` (`carrier_claim:primary_or_first`) is now valid. It has no
|
|
23
|
+
results, because no carrier claim record in the test data has a primary or first code.
|
|
24
|
+
- `provenance` takes the keyword `"*"`. `["provenance", <stream>, "*"]` keeps the records
|
|
25
|
+
whose file provenance or code provenance is any provenance concept, and drops the records
|
|
26
|
+
with neither. It builds the SQL that `provenance` with no keyword built before. Keywords are
|
|
27
|
+
ORed, so `"*"` with other keywords keeps the same records and builds the same SQL as `"*"`
|
|
28
|
+
alone. The other keywords are still checked, so an unrecognized one beside `"*"` is still
|
|
29
|
+
the error `unrecognized keywords`. A `"*"` half in a `<file keyword>:<code keyword>`
|
|
30
|
+
keyword, such as `"inpatient:*"`, is that error too.
|
|
31
|
+
- `place_of_service_filter` takes the code `"*"`. `["place_of_service_filter", <stream>, "*"]`
|
|
32
|
+
keeps the records whose place of service is any concept of the CMS Place of Service
|
|
33
|
+
vocabulary. It drops the records with no place of service, and the records whose place of
|
|
34
|
+
service is a concept of another vocabulary. This is the meaning of `"*"` in `provenance`.
|
|
35
|
+
Codes are ORed, so `"*"` with other codes keeps the same records as `"*"` alone. The other
|
|
36
|
+
codes are still checked, so an unknown code beside `"*"` is still the error
|
|
37
|
+
`unknown code(s)`. Before, `"*"` was itself that error.
|
|
38
|
+
- `ConceptQL::Lexicon.for_dataset(db)` makes a DuckDB dataset its own lexicon. The returned
|
|
39
|
+
Lexicon reads the OHDSI vocabulary tables (`concept`, `concept_ancestor`,
|
|
40
|
+
`concept_relationship` and `vocabulary`) from `db` itself, through its search path, and
|
|
41
|
+
caches its lookups. It never reads `LEXICON_URL`. Pass it as
|
|
42
|
+
`ConceptQL::Database.new(db, lexicon: lexicon)`, without `cache_lookups`. When the dataset
|
|
43
|
+
lacks one of the tables, it raises `ConceptQL::Lexicon::MissingVocabularyError`, whose
|
|
44
|
+
message names the missing tables, the schemas that it searched and the search path. The
|
|
45
|
+
error's `missing_tables` attribute holds the missing tables, and its `schemas` attribute
|
|
46
|
+
holds the searched schemas, each as `catalog.schema`. `for_dataset` returns nil for a
|
|
47
|
+
Postgres or Spark db, and the Database chooses its lexicon as before.
|
|
48
|
+
- `sum` takes the boolean option `group_by_person`. With it, `sum` gives one person result
|
|
49
|
+
for each person (and window): `person_id`, `criterion_domain` `person`, `criterion_table`
|
|
50
|
+
of the person table (as a cast to `person` gives them), `criterion_id` 0, the earliest
|
|
51
|
+
`start_date`, the latest `end_date`, and the sum of `value_as_number`. The other columns
|
|
52
|
+
are NULL. `["sum", ["numeric", 1, <stream>], {"group_by_person": true}]` counts the events
|
|
53
|
+
of each person. Without the option, `sum` groups as before: by every column except the
|
|
54
|
+
dates, `criterion_id` and `value_as_number`, so one person can get several rows. A value
|
|
55
|
+
other than true or false is the validation error `wrong option format`.
|
|
56
|
+
- The operators `ms_drg` and `cms_drg` select DRG events by DRG system. CMS renumbered the
|
|
57
|
+
DRG codes on 2007-10-01, when MS-DRG replaced CMS-DRG, and reused most codes for new
|
|
58
|
+
meanings. An event row does not record which meaning applied, so each operator selects the
|
|
59
|
+
DRG code and the event dates of its system: `ms_drg` selects the events whose `start_date`
|
|
60
|
+
is on or after 2007-10-01, and `cms_drg` the events before that day. The day is the
|
|
61
|
+
constant `ConceptQL::Vocabularies::DrgSystems::MS_DRG_START_DATE`. Both operators look up
|
|
62
|
+
concept IDs in the DRG vocabulary as `drg` does, so they work with the OHDSI and the GDM
|
|
63
|
+
lexicons. They check codes against the DRG format of vocabulary_formats (3 digits) and warn
|
|
64
|
+
about unknown codes as `drg` does. They never give the warning
|
|
65
|
+
`code(s) with more than one meaning`. A code list names each code by its meaning in the
|
|
66
|
+
operator's system when the lexicon keeps OHDSI concept classes (`MS-DRG` or `DRG`), and by
|
|
67
|
+
all of its names otherwise. The operator metadata lists both operators in the category of
|
|
68
|
+
`drg`, with the preferred names `MS-DRG` and `CMS-DRG`.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- **Breaking:** codes match case-exactly. A code selects a concept only when it has the case
|
|
73
|
+
of the concept's `concept_code`, so `["icd10cm", "I16.5"]` selects I16.5 as before and
|
|
74
|
+
`["icd10cm", "i16.5"]` selects no rows. A code in the wrong case also gets the warning
|
|
75
|
+
`improperly formatted code`, because the format check no longer ignores case. Before, the
|
|
76
|
+
lookup compared `lower(concept_code)` with the code in lower case, and the format check
|
|
77
|
+
compiled each vocabulary_formats pattern with an ignore-case flag. A vocabulary can still
|
|
78
|
+
ignore case: its vocabulary_formats row declares it with the scoped flag `(?i:...)`. Then
|
|
79
|
+
the format check honors the flag, and the lookup compares `lower(concept_code)`. No row
|
|
80
|
+
declares it today. The unknown-code warning compared case exactly before and still does.
|
|
81
|
+
A survey of the 1,259 production algorithms (2026-10-31) found no code that matches only
|
|
82
|
+
when case is ignored. Its 150 lower-case codes, all SEER staging codes such as `cX` and
|
|
83
|
+
`M1a`, match their concept codes exactly. The `snf` operator looked up its file provenance
|
|
84
|
+
with the code `SNF`, which found the concept `snf` only while the lookup ignored case. It now
|
|
85
|
+
uses `snf`, so it selects the same records as before.
|
|
86
|
+
- **Breaking:** the pinned vocabulary_formats table (`config/vocabulary_formats.tsv`) is
|
|
87
|
+
vocabulary_formats commit `dbd670ae17be0afa3002ce40e5e327c509e62f9c`, which makes every row
|
|
88
|
+
case-exact. The default rows of ICD10CM, ICD10, ICD10PCS, HCPCS, CPT4, CPT4_HCPCS, the
|
|
89
|
+
`*_MODIFIER` vocabularies, ATC, ICDO3, ICD03_Topography and EDI no longer accept lower-case
|
|
90
|
+
letters, and the rows that used `\w` accept only digits and upper-case letters in its place.
|
|
91
|
+
- Provenance keywords and the codes of `place_of_service_filter` match case-exactly too,
|
|
92
|
+
because their vocabularies have no vocabulary_formats row. `"Inpatient"` is the validation
|
|
93
|
+
error `unrecognized keywords`, as in 0.5.0, and the plain query raises
|
|
94
|
+
`ConceptQL::InvalidStatementError` for it. Every provenance concept code is in lower case.
|
|
95
|
+
The unreleased change that made the keyword validation ignore case is withdrawn.
|
|
96
|
+
- **Breaking:** `episode` merges a record that starts inside an episode and ends after it. A
|
|
97
|
+
record now stays in the episode when its start_date is no more than `gap_of` days after the
|
|
98
|
+
latest end_date of the episode's earlier records. A NULL end_date counts as the start_date.
|
|
99
|
+
Before, the operator compared that end plus `gap_of` with the latest end_date including the
|
|
100
|
+
record itself, so a record that ended more than `gap_of` days after the episode began a new
|
|
101
|
+
episode, even when it started inside the episode. For example, the records 2020-01-01 to
|
|
102
|
+
2020-01-10 and 2020-01-05 to 2020-03-01 gave two overlapping episodes with `gap_of` 0 or
|
|
103
|
+
30, and now give one episode from 2020-01-01 to 2020-03-01. The result changes only for
|
|
104
|
+
records that last more than one day. Records of one day give the same episodes as before,
|
|
105
|
+
so the test statement `episode/results_icd9_episodes_w_30d_gap.json` returns the same rows.
|
|
106
|
+
Statements that make episodes of records with durations, such as drug exposures or
|
|
107
|
+
hospital stays, return fewer and longer episodes.
|
|
108
|
+
- **Breaking:** `provenance` with no keyword fails validation with
|
|
109
|
+
`["no provenance keywords; use \"*\" for any provenance"]`, and its plain query raises
|
|
110
|
+
`ConceptQL::InvalidStatementError`. The error does not need a database. Before,
|
|
111
|
+
`["provenance", <stream>]` kept every record with any provenance. Write
|
|
112
|
+
`["provenance", <stream>, "*"]` for that. No production algorithm used the empty form on
|
|
113
|
+
2026-10-31.
|
|
114
|
+
- **Breaking:** a Database on a real database without vocabulary raises
|
|
115
|
+
`ConceptQL::Lexicon::NoVocabularyError`. `ConceptQL::Database.new(db)` raises it when `db`
|
|
116
|
+
is a real database (not nil and not a Sequel mock) and neither `db` nor the `LEXICON_URL`
|
|
117
|
+
database has all the OHDSI vocabulary tables (`concept`, `concept_ancestor`,
|
|
118
|
+
`concept_relationship`, `vocabulary`) or all the GDM ones (`ancestors`, `concepts`,
|
|
119
|
+
`mappings`, `vocabularies`). Before, such a Database silently used no vocabulary: every code
|
|
120
|
+
matched no rows and the unknown-code warnings turned off, so `["icd9", "412"]` returned 0
|
|
121
|
+
rows instead of 48. Code lists are not exempt. The message names the data database (never
|
|
122
|
+
its user or password), its search path on Postgres and DuckDB, the missing tables of each
|
|
123
|
+
kind in each database searched, whether `LEXICON_URL` is set, and the fixes. `#searches`
|
|
124
|
+
holds the same facts. A Database with no db or a Sequel mock (diagram and SQL rendering)
|
|
125
|
+
still uses no vocabulary, and a Database given `lexicon:` (for example
|
|
126
|
+
`ConceptQL::Lexicon.for_dataset(db)`) is unaffected. `Lexicon::MissingVocabularyError` is a
|
|
127
|
+
subclass of `NoVocabularyError`, so one `rescue` covers both.
|
|
128
|
+
- **Breaking:** the plain query raises `ConceptQL::InvalidStatementError` for a statement with
|
|
129
|
+
validation errors. `Query#query`, `#sql` and `#sql_statements`, and `#all`, `#count`,
|
|
130
|
+
`#execute`, `#order` and `#analyze` through `#query`, now validate the statement first,
|
|
131
|
+
as `#code_list` already did. The error's message lists each error that `annotate` shows,
|
|
132
|
+
with the name of its operator. `#errors` holds the same errors as the `errors` hash of
|
|
133
|
+
`scope_annotate`, and `#statement` holds the statement. Warnings do not raise. Examples of
|
|
134
|
+
warnings are the `count` and `drg` deprecations, an unknown code and an improperly formatted
|
|
135
|
+
code. Before, an invalid statement could crash while it built (`provider_filter` without
|
|
136
|
+
`roles` raised NoMethodError, and an unknown operator raised RuntimeError
|
|
137
|
+
`Invalid#query called`). It could also build SQL that silently returned fewer rows.
|
|
138
|
+
`provenance` ignored an unrecognized keyword, such as a concept ID or
|
|
139
|
+
`inpatient:primary_or_first`, and `count` ignored every upstream after the first. Those
|
|
140
|
+
statements now raise. An invalid window table and an empty statement also raise it (see
|
|
141
|
+
Fixed). The error is a `Query::QueryError`, and its old name
|
|
142
|
+
`ConceptQL::Query::InvalidStatementError` still works. `annotate` does not raise.
|
|
143
|
+
- **Breaking:** `numeric` without an upstream fails validation when its column name is not a
|
|
144
|
+
column of the person table. That form (`["numeric", "<name>"]`) reads the column from the
|
|
145
|
+
person table (`patients` in gdm and gdm_wide). A name that the table lacks is now the
|
|
146
|
+
validation error `["unknown column", "<name>"]`, and `annotate` runs no counting query for
|
|
147
|
+
the operator. This includes a column that only stream rows have, such as `criterion_id`. In
|
|
148
|
+
0.5.0 on DuckDB, `["numeric", "criterion_id"]` ran and returned 249 rows on the test data.
|
|
149
|
+
Now it fails validation, and its plain query raises `ConceptQL::InvalidStatementError`. A
|
|
150
|
+
name that is neither a column of the person table nor an output column, such as
|
|
151
|
+
`no_such_column`, also failed in 0.5.0, but only at run time. The match ignores case, as
|
|
152
|
+
DuckDB and Spark do.
|
|
153
|
+
- **Breaking:** `filter`, `except` and `person_filter` with an `as` option fail validation with
|
|
154
|
+
`["unsupported option", "as", <value>]`. None of them has an `as` option. In 0.5.0, the
|
|
155
|
+
statement passed validation and ran as a plain filter, except or person_filter, so a user
|
|
156
|
+
who meant to cast both streams got other rows with no warning. Now the plain query of such a
|
|
157
|
+
statement raises `ConceptQL::InvalidStatementError`. A blank `as` counts as not set. Other
|
|
158
|
+
options that these operators read but do not declare (`ignore_dates` for filter and except,
|
|
159
|
+
and the t_shank-only options) still validate.
|
|
160
|
+
- **Breaking:** `union` removes exact duplicate rows. A row that arrives from more than one
|
|
161
|
+
upstream comes out once, because every stream is a set. Before, `union` joined its
|
|
162
|
+
non-vocabulary upstreams with `UNION ALL`, so
|
|
163
|
+
`["union", ["first", ["icd9", "412"]], ["icd9", "412"]]` returned each first MI twice. Two
|
|
164
|
+
rows are duplicates only when every column is equal, `label` included. The duplicate
|
|
165
|
+
removal is expensive on large data, so `union` keeps `UNION ALL` when no row can come from
|
|
166
|
+
two of its upstreams: when the upstreams are vocabulary operators, `cpt_or_hcpcs`-style
|
|
167
|
+
operators and unions of them, and each pair has different domains or different
|
|
168
|
+
vocabularies. Statements that union a labeled operator with a `recall` of it return fewer
|
|
169
|
+
rows.
|
|
170
|
+
- **Breaking:** `trim_date_end` and `trim_date_start` with `compare_all` return one row for
|
|
171
|
+
each left row. With `compare_all`, each left row is compared with every right row of the
|
|
172
|
+
person (and of the `join_columns`). `trim_date_end` now trims the end_date to the earliest
|
|
173
|
+
right start_date on or after the left start_date. `trim_date_start` trims the start_date to
|
|
174
|
+
the latest right end_date on or before the left end_date (each date as `within` moves it,
|
|
175
|
+
when `within` is given). Before, the left row came out once for each such
|
|
176
|
+
right row: trimmed by each one, and also untrimmed when a right row started after the left
|
|
177
|
+
row ended. For example,
|
|
178
|
+
`["trim_date_end", {"left": ["icd9", "412"], "right": ["cpt", "99214"], "compare_all": true}]`
|
|
179
|
+
returned event 523 twice, with the end dates 2008-03-17 and 2008-03-21, and now returns it
|
|
180
|
+
once, with 2008-03-17. With `["icd9", "412"]` as both the left and the right, `trim_date_end`
|
|
181
|
+
returned 60 rows for 48 left events, and now returns 48. Which left rows come out does not
|
|
182
|
+
change: a left row is dropped only when it has right rows and none of them passes, and a
|
|
183
|
+
left row without right rows passes through unchanged. t_shank sets `compare_all` for its
|
|
184
|
+
baseline windows. Statements that count the rows of such a trim count fewer rows.
|
|
185
|
+
- **Breaking:** `trim_date_end` and `trim_date_start` with `join_columns` and without
|
|
186
|
+
`compare_all` trim each left row by the first (`trim_date_end`) or last (`trim_date_start`)
|
|
187
|
+
right row of its person and its `join_columns` values. That is the grouping that
|
|
188
|
+
`compare_all` uses. Before, the operator took the person's first or last right row and only
|
|
189
|
+
then joined on the `join_columns`, so a left row whose join values differed from that row's
|
|
190
|
+
found no right row and passed through untrimmed. For example, `trim_date_end` with
|
|
191
|
+
`["icd9", "412", {"uuid": true}]` as the left, the same events 30 days earlier as the right,
|
|
192
|
+
and `"join_columns": ["uuid"]` returned 11 of the 48 events, one for each event that was
|
|
193
|
+
not the first of its person, and now returns none. t_shank sets `compare_all` whenever it
|
|
194
|
+
gives `join_columns`, so its exports do not change.
|
|
195
|
+
- **Breaking:** the `uuid` column (the option `uuid`) gives a NULL part as an empty string.
|
|
196
|
+
The uuid joins `person_id`, `criterion_id`, `criterion_table`, `start_date` and `end_date`
|
|
197
|
+
with `/`. Before, one NULL part, such as an event without an `end_date`, made the whole uuid
|
|
198
|
+
NULL. A NULL uuid matches nothing, so `trim_date_end` or `trim_date_start` with
|
|
199
|
+
`"join_columns": ["uuid"]` passed such a window through untrimmed. Now an event without an
|
|
200
|
+
`end_date` has a uuid such as `1/2/observations/2008-03-10/`. A uuid without a NULL part
|
|
201
|
+
does not change.
|
|
202
|
+
- **Breaking:** a date adjustment that is a bare number now acts as the same number of days
|
|
203
|
+
with the unit `d`. With an `r`, `e` or `s` prefix, `R365` means 365 days back from the origin, `E30` means `end_date` +
|
|
204
|
+
30 days, and `S7` means `start_date` + 7 days, the same as `R365d`, `E30d` and `S7d`. In
|
|
205
|
+
0.5.0, these strings gave no adjustment and no error. t_shank puts `R` before lookbacks and
|
|
206
|
+
blackout periods, so a lookback of `R365` was a lookback of zero days. ConceptQL now also
|
|
207
|
+
reverses a bare number where it reverses an adjustment with a unit, such as `365d`. Those
|
|
208
|
+
places are the `within` of a temporal operator, the `at_least` of `before`, and the `start`
|
|
209
|
+
of `concurrent_within`. In 0.5.0, these options added a bare number to the date, and now
|
|
210
|
+
they count it back from the date. So a statement with such a value selects a different date
|
|
211
|
+
range than in 0.5.0. `DateAdjuster#adjustments` also no longer returns the result of an
|
|
212
|
+
earlier call that had a different `reverse` value.
|
|
213
|
+
- The code formats come from vocabulary_formats. conceptql no longer keeps its own format
|
|
214
|
+
patterns. A code that does not match its vocabulary's format is still the warning
|
|
215
|
+
`improperly formatted code`, which names the code, as in 0.5.0. The statement stays valid:
|
|
216
|
+
`annotate` counts the operator, the plain query and `Query#code_list` run, and the
|
|
217
|
+
malformed code matches no rows. A code is never a prefix (`["icd9", "410*"]` does not
|
|
218
|
+
select `410.00`), and a bare `"*"` still selects every code. A `cpt_or_hcpcs`-style operator
|
|
219
|
+
gives the warning only when every one of its vocabularies rejects the code's format.
|
|
220
|
+
`config/vocabulary_formats.tsv` is a copy of the published table of the public repository
|
|
221
|
+
`outcomesinsights/vocabulary_formats`, and `config/vocabulary_formats.sha` records its
|
|
222
|
+
commit. `just vocabulary-formats <ref>` refreshes both. Each vocabulary uses its default row
|
|
223
|
+
(the row whose `vocabulary_id` is the vocabulary's id), anchored as `\A(?:pattern)\z`. A
|
|
224
|
+
vocabulary without a row gets no format check, and an unknown code in it is still the warning
|
|
225
|
+
`unknown code(s)`. Compared with 0.5.0: LOINC and Gemscript are no longer checked, so
|
|
226
|
+
standard LOINC codes such as `20-8` and `1-8` no longer get the format warning. ICD10PCS
|
|
227
|
+
accepts the hierarchy codes of 1 to 7 characters (`0016`). NDC accepts only the 11-digit
|
|
228
|
+
claims form, so the 9-digit product codes (`003630641`) and the alphanumeric codes
|
|
229
|
+
(`00019N068`) now get the format warning. RxNorm Extension, ICDO3 and EDI, which come from
|
|
230
|
+
the lexicon, are now checked. The other patterns are unchanged. `config/vocabularies.csv`
|
|
231
|
+
has no `format_regexp` column, and a `format_regexp` column in a custom vocabularies file is
|
|
232
|
+
ignored. The `format_regexp` of a vocabulary entry's metadata is the anchored pattern from
|
|
233
|
+
vocabulary_formats.
|
|
234
|
+
- The format warning of an NDC code says why the code matches nothing:
|
|
235
|
+
`improperly formatted code (claims use 11-digit package NDCs, so it matches no rows)`. The
|
|
236
|
+
claims carry the 11-digit package NDC, so a code in another form selects no rows. The
|
|
237
|
+
warning keeps its shape, the message and then the codes, and the message still starts with
|
|
238
|
+
`improperly formatted code`. The other vocabularies keep the plain text.
|
|
239
|
+
- `occurrence` with `at_least` returns each row once, because every stream is a set. Each
|
|
240
|
+
earlier row of a person picks its nth row, and with `at_least` several earlier rows can pick
|
|
241
|
+
the same row. Before, that row came out once for each earlier row that picked it, so
|
|
242
|
+
`["occurrence", 2, ["icd9", ...], {"at_least": "30d"}]` returned 2366 rows, of which only
|
|
243
|
+
1163 were distinct. The same rows come out, and only the copies are gone. Statements that
|
|
244
|
+
count the rows of such an `occurrence` count fewer rows.
|
|
245
|
+
- `cpt_or_hcpcs` joins its vocabulary branches (CPT4, HCPCS and CPT4_HCPCS) with `UNION ALL`
|
|
246
|
+
instead of `UNION`. Each branch selects the rows of a different vocabulary, so no row can
|
|
247
|
+
come from two branches. The duplicate removal was the most expensive step of a
|
|
248
|
+
`cpt_or_hcpcs "*"` query, and it ran out of memory on full SEER-Medicare data. A row that
|
|
249
|
+
one branch repeats (for example, through a window table with repeated rows) now stays,
|
|
250
|
+
as it does for `cpt` and `hcpcs`. A custom multiple-vocabularies file that lists one
|
|
251
|
+
vocabulary twice keeps `UNION` for that operator.
|
|
252
|
+
- `Query#annotate` and `Query#scope_annotate` with counts on now count every operator with
|
|
253
|
+
one SQL statement on DuckDB and Postgres. The statement holds the root query's
|
|
254
|
+
`MATERIALIZED` CTEs and a `UNION ALL` of one count for each operator's CTE. An operator
|
|
255
|
+
that the root query does not evaluate (a vocabulary operator that `union` combines with
|
|
256
|
+
others) adds its own CTE to the statement. Before, each operator ran its own counting
|
|
257
|
+
query, which evaluated all of its upstream operators again. The counts and the output
|
|
258
|
+
shape do not change, and an operator with no rows still reports zeros. Spark does not
|
|
259
|
+
honor `MATERIALIZED`, so it keeps one counting query for each operator. The old path is
|
|
260
|
+
also used when the statement has an operator that annotate does not count (an invalid
|
|
261
|
+
operator, for example), when temp tables replace the CTEs (`force_temp_tables`), and when
|
|
262
|
+
`CONCEPTQL_AVOID_CTES=true` turns the CTEs off. It is also used when the Database has the
|
|
263
|
+
`share_utilization` option and the statement has a `hospitalization`, `snf` or `hospice`
|
|
264
|
+
operator, because the shared tables are not CTEs. `skip_counts: true` runs no counting
|
|
265
|
+
query, as before.
|
|
266
|
+
- `annotate` with one counting query for each operator (Spark, temp tables and the other
|
|
267
|
+
cases above) builds its SQL faster. The data model found the vocabulary column of each
|
|
268
|
+
table again each time it built an operator's columns, and now finds it once. The SQL and the
|
|
269
|
+
counts do not change. On a 111-operator production algorithm on DuckDB, that `annotate`
|
|
270
|
+
fell from 36 s to 30 s.
|
|
271
|
+
- The gemspec accepts sequelizer 0.5 (`>= 0.3.0, < 0.6`; before, `< 0.5`), so an
|
|
272
|
+
application can use sequelizer 0.5's `dataset_db` and `with_dataset` with conceptql.
|
|
273
|
+
conceptql calls neither of them, and the parts of sequelizer that conceptql uses did not
|
|
274
|
+
change in 0.5. In sequelizer 0.5, an unqualified table name reads a dataset only inside
|
|
275
|
+
`with_dataset`, so call `ConceptQL::Lexicon.for_dataset` and run the queries inside that
|
|
276
|
+
block. conceptql's own Gemfile stays on the sequelizer 0.4 release.
|
|
277
|
+
- The SQL of a statement is smaller and takes less time to plan. The rows do not change.
|
|
278
|
+
- CTEs with the same SQL and the same WITH options merge into one. A statement evaluates
|
|
279
|
+
some operators more than once (a `recall`, for example), and each evaluation used to add
|
|
280
|
+
its own copy.
|
|
281
|
+
- An IN or NOT IN list of at least `ConceptQL::Scope::CONCEPT_LIST_MIN_ITEMS` (1,000)
|
|
282
|
+
integers, such as a list of provenance concept IDs, goes to the database once. It becomes
|
|
283
|
+
a `VALUES` CTE that each copy of the list reads. The CTE is `MATERIALIZED` on the
|
|
284
|
+
databases that support it, which excludes Spark. A shorter list stays an IN list. On
|
|
285
|
+
Spark, a list in a SELECT-list expression (the CASE flags of `one_in_two_out`) stays an
|
|
286
|
+
IN list, because Spark accepts an IN subquery only in a filter or a join. On Spark, a
|
|
287
|
+
NOT IN list also stays a NOT IN list, because Spark rejects a NOT IN subquery in a nested
|
|
288
|
+
condition, such as the `IS NULL OR NOT IN` of `gender`.
|
|
289
|
+
- On a 111-operator production algorithm, DuckDB planning of the plain query fell from
|
|
290
|
+
0.81 s to 0.57 s and `annotate` with counts from 2.7 s to 2.0 s. Postgres planning of the
|
|
291
|
+
same statement on the test data fell from 480 ms to 152 ms.
|
|
292
|
+
- The specification (`doc/spec.md`) describes the changed `union` and `filter`, the new
|
|
293
|
+
`ms_drg` and `cms_drg`, the `count` and `drg` deprecations, and `sum` with
|
|
294
|
+
`group_by_person`. It gives the full format of a date adjustment, with the unit `w` and the
|
|
295
|
+
prefixes `r`, `e` and `s`. New sections describe `trim_date_end` and `trim_date_start`
|
|
296
|
+
(with `compare_all`) and the `window` statement with a window table. It says that ConceptQL
|
|
297
|
+
raises an error when it finds no vocabulary for a real database.
|
|
298
|
+
|
|
299
|
+
### Deprecated
|
|
300
|
+
|
|
301
|
+
- The `count` operator is deprecated. Every stream is a set, so `count` returns 1 for nearly
|
|
302
|
+
every row. A statement that uses `count` still runs and returns the same rows. Annotation
|
|
303
|
+
adds the warning `deprecated operator` on `count`, and a later release will make it an
|
|
304
|
+
error. To count the events of each person, use
|
|
305
|
+
`["sum", ["numeric", 1, <stream>], {"group_by_person": true}]`. Without `group_by_person`,
|
|
306
|
+
`sum` splits the count of one person over several rows when the events have different
|
|
307
|
+
source values. `ConceptQL::Operators::Count.to_metadata` says `deprecated: true`.
|
|
308
|
+
`ConceptQL.metadata` does not list `count`, because it lists only the operators that have a
|
|
309
|
+
category, and `count` has none. The new class-level declaration `deprecated` marks an
|
|
310
|
+
operator.
|
|
311
|
+
- The `drg` operator is deprecated. A DRG code can have two meanings, and `drg` selects the
|
|
312
|
+
events of both. A statement that uses `drg` still runs and returns the same rows.
|
|
313
|
+
Annotation adds the warning `deprecated operator` on `drg`, and a later release will make
|
|
314
|
+
it an error. Use `ms_drg` for the MS-DRG meaning (events from 2007-10-01) or `cms_drg` for
|
|
315
|
+
the CMS-DRG meaning (events before 2007-10-01). The operator metadata of `drg` says
|
|
316
|
+
`deprecated: true`.
|
|
317
|
+
|
|
318
|
+
### Removed
|
|
319
|
+
|
|
320
|
+
- **Breaking:** the `concurrent_within` operator is gone. A statement that names it gets the
|
|
321
|
+
validation error `invalid operator`, as any unknown operator does, and its plain query
|
|
322
|
+
raises `ConceptQL::InvalidStatementError`. No production algorithm used it on 2026-10-31:
|
|
323
|
+
none of the 1,652 algorithm rows (current, old and deleted) named it. Its test statements
|
|
324
|
+
and expected results are removed with it. The SQL of every other operator does not change.
|
|
325
|
+
- The unused methods `query_cols` of the `race` and `ethnicity` operators and the unused class
|
|
326
|
+
method `Operator.query_columns` are removed. Nothing called them, and the two `query_cols`
|
|
327
|
+
methods would have raised `NoMethodError`. The `race` and `ethnicity` operators are not
|
|
328
|
+
affected: their SQL and their results do not change. `Query#query_cols` is a different
|
|
329
|
+
method and stays.
|
|
330
|
+
- The hidden vocabulary operators `place_of_service` (alias `place of service`) and
|
|
331
|
+
`specialty` are removed, with their rows in `config/vocabularies.csv`. Neither one worked.
|
|
332
|
+
Their rows named the vocabularies `Place of Service` and `Specialty`, but the OHDSI and the
|
|
333
|
+
GDM lexicons name them `CMS Place of Service` and `Medicare Specialty`. So
|
|
334
|
+
`place_of_service` selected no record and gave no warning. `specialty` read the column
|
|
335
|
+
`clinical_code_vocabulary_id` from `practitioners`, which has no such column, so its query
|
|
336
|
+
failed with a database error. No production algorithm used either name. Now each name is
|
|
337
|
+
the validation error `invalid operator`. The lexicon's own operators for these
|
|
338
|
+
vocabularies, `cms_place_of_service` (also written `cms place of service`) and
|
|
339
|
+
`medicare_specialty`, do not change. To keep records by their place of service or their
|
|
340
|
+
provider's specialty, use `place_of_service_filter` or `provider_filter`. The SQL of every
|
|
341
|
+
other statement is unchanged.
|
|
342
|
+
|
|
343
|
+
### Fixed
|
|
344
|
+
|
|
345
|
+
- `place_of_service_filter` selects records again. It looked up its codes in the vocabulary
|
|
346
|
+
`Place of Service`, but the OHDSI and the GDM lexicons name that vocabulary
|
|
347
|
+
`CMS Place of Service`. So no code became a concept ID, and the operator kept no record on
|
|
348
|
+
any data. Now `["place_of_service_filter", <stream>, "21"]` keeps the records whose place of
|
|
349
|
+
service (`pos_concept_id` of the record's context in gdm, of the observation in gdm_wide)
|
|
350
|
+
is the concept of code 21, Inpatient Hospital. A record with no place of service is still
|
|
351
|
+
dropped. A code that is not in the vocabulary is now the validation error
|
|
352
|
+
`unknown code(s)`, with the code as written, and the plain query raises
|
|
353
|
+
`ConceptQL::InvalidStatementError` for it. A code can never match a record when it names no
|
|
354
|
+
concept, so this is an error and not a warning. The codes have two digits, so the number
|
|
355
|
+
`1` is an unknown code and `"01"` is Pharmacy. Without a lexicon, no code is checked. The
|
|
356
|
+
argument metadata names the vocabulary `CMS Place of Service`. The SQL of the operator now
|
|
357
|
+
has the concept IDs of its codes. The SQL of every other statement is unchanged.
|
|
358
|
+
- `CONCEPTQL_AVOID_CTES=true` works again. Since 2022, each operator read its rows from a CTE
|
|
359
|
+
of its own, but in this mode the statement had no WITH list. So the database refused the
|
|
360
|
+
SQL of every statement because the CTE did not exist. Now each operator is a
|
|
361
|
+
subquery in this mode, and a long concept list stays an IN list. The operators `occurrence`
|
|
362
|
+
(with `at_least` or `within`) and `co_reported` still use CTEs of their own, inside their
|
|
363
|
+
subquery. With the Database's `share_utilization` option, the statement reads the shared
|
|
364
|
+
table as in the default mode. The SQL of the default mode is unchanged.
|
|
365
|
+
- An empty statement (`[]`) is now an invalid statement. `validation_errors` and
|
|
366
|
+
`scope_annotate` report the error `{"invalid" => [["empty statement"]]}`, and `query`, `sql`
|
|
367
|
+
and the other methods that build SQL raise `ConceptQL::InvalidStatementError`. This also
|
|
368
|
+
applies to an empty operand (`["union", [], ["icd9", "412"]]`), to `[[]]`, to a `window`
|
|
369
|
+
around `[]`, and to an `algorithm` whose stored statement is `[]`. Before, each of these
|
|
370
|
+
raised `ArgumentError` in `Nodifier#create`.
|
|
371
|
+
- The statement `[nil]` or `[""]` reports its error under the key `invalid`:
|
|
372
|
+
`{"invalid" => [["invalid operator", ""]]}`. Before, the key was `""`.
|
|
373
|
+
- `Operators::Invalid#operator_name` gives the operator name of the statement (`nope_op` for
|
|
374
|
+
`["nope_op", "412"]`), and `invalid` when the statement has no name. Before, it removed the
|
|
375
|
+
first code from the arguments and gave that code (`412`).
|
|
376
|
+
- A `ConceptQL::Database` on a Sequel mock (`Sequel.mock`) now reads its vocabulary from the
|
|
377
|
+
`LEXICON_URL` database. A mock answers yes to every table check, so the Lexicon chose the
|
|
378
|
+
mock as an OHDSI vocabulary that has no rows, and every vocabulary from the lexicon was an
|
|
379
|
+
invalid operator. Now the Lexicon never searches a mock data database for vocabulary tables.
|
|
380
|
+
Without `LEXICON_URL`, a mock gets `LexiconNoDB`, and `ConceptQL::Database.new` raises no
|
|
381
|
+
`NoVocabularyError` for it.
|
|
382
|
+
- `annotate` reports the errors of a window table that is an invalid ConceptQL statement.
|
|
383
|
+
`scope_annotate` reports them under the key `window_table`, and each message starts with
|
|
384
|
+
the name of its operator (for example
|
|
385
|
+
`["provider_filter: required option not present", "roles"]`). `annotate` then counts
|
|
386
|
+
nothing, because every count joins the window table. The plain query of the outer
|
|
387
|
+
statement raises `InvalidStatementError` with these errors and the outer statement. In
|
|
388
|
+
0.5.0, `annotate` did not validate the window table, so it reported no error for it, and a
|
|
389
|
+
counted `annotate` built the window table's SQL without a check.
|
|
390
|
+
- `inner_join` and `include_rhs_columns` no longer return a row more than once. With either
|
|
391
|
+
option, a temporal operator (for example `during`, `contains` or `any_overlap`) joins each
|
|
392
|
+
left row to every matching right row, and it gave one copy of the left row for each match.
|
|
393
|
+
On the DuckDB test data, `during` of `icd9 412` in `information_periods` with
|
|
394
|
+
`include_rhs_columns: ["criterion_domain"]` gave 176 rows for 48 records. Each row now comes
|
|
395
|
+
out once. With `include_rhs_columns`, rows that differ in a right-side column are different
|
|
396
|
+
rows, so a left row still comes out once for each different value, such as each right
|
|
397
|
+
`uuid`.
|
|
398
|
+
- Building a statement's SQL no longer runs a query to learn a dataset's columns. Before,
|
|
399
|
+
`occurrence` with `group_by_date` over a labeled upstream failed on every database with
|
|
400
|
+
"Table with name <label> does not exist" (Postgres: "relation ... does not exist"): it asked
|
|
401
|
+
the database for the columns of the labeled operator's CTE, and that CTE does not exist
|
|
402
|
+
while the SQL is built. A `recall` of the label later in the statement did not help. The
|
|
403
|
+
same kind of query ran in three other places. `include_rhs_columns` on a temporal operator
|
|
404
|
+
(for example `contains`) failed the same way when its right side was labeled. A
|
|
405
|
+
`window_table` scope window and the provider columns of an operator that reads the person
|
|
406
|
+
table ran the query on every build. Each of these now names its columns, so no query runs.
|
|
407
|
+
The test suite now fails when building any test statement's SQL asks the database for
|
|
408
|
+
columns.
|
|
409
|
+
- `numeric` with an upstream and a column name (`["numeric", "quantity", stream]`) now
|
|
410
|
+
fails validation when the upstream's rows lack that column. The error is
|
|
411
|
+
`["unknown column", "<name>"]`, so `annotate` reports it and runs no counting query.
|
|
412
|
+
Before, the statement passed validation and failed at run time with a "column not found"
|
|
413
|
+
error. The column is read from the rows that the upstream emits, not from the tables that
|
|
414
|
+
the upstream reads. Every stream emits the same columns, so an upstream that mixes tables
|
|
415
|
+
(a `union` of two domains, for example) gets the same check. The match ignores case, as
|
|
416
|
+
DuckDB and Spark do. `numeric` without an upstream gets the same check against the person
|
|
417
|
+
table (see Changed).
|
|
418
|
+
- `numeric` with a column name in another case than the column's own
|
|
419
|
+
(`["numeric", "Value_As_Number", stream]`) now runs on Postgres. Before, the SQL quoted the
|
|
420
|
+
name as written, and Postgres found no such column. The SQL now uses the column's own name,
|
|
421
|
+
so validation and the SQL agree on every database.
|
|
422
|
+
- `Query#annotate` now gives each operator's counts (`annotation[:counts]`) in domain-name
|
|
423
|
+
order, with both counting paths (one SQL statement, or a query for each operator). Before,
|
|
424
|
+
the order came from the database, and the zero counts of domains with no rows came last.
|
|
425
|
+
On DuckDB, the order of a multi-domain operator's domains changed from run to run, so a
|
|
426
|
+
diagram drawn from the counts could change between identical runs. The keys and the
|
|
427
|
+
numbers are the same as before.
|
|
428
|
+
- `filter` now matches a left record only to a right record with the same
|
|
429
|
+
`criterion_table`, as well as the same person, `criterion_id` and `criterion_domain`. A
|
|
430
|
+
`criterion_id` is an id within its table. Before, a record matched a record from another
|
|
431
|
+
table of the same domain whenever their ids were equal. For example,
|
|
432
|
+
`hospitalization` records (table `collections`, domain `condition_occurrence`) matched
|
|
433
|
+
`icd9` records from `observations`: `filter(icd9 "*" by hospitalization, ignore_dates)`
|
|
434
|
+
kept 8 such records on the test data, and now keeps none.
|
|
435
|
+
- `DataModel::Base#table_columns` no longer prints its result and its table names to
|
|
436
|
+
stdout. The two `p` calls were debug output from 2019. No statement in the test suite
|
|
437
|
+
calls this method, so the SQL and the results are unchanged.
|
|
438
|
+
- `ConceptQL.metadata` serves the `desc` of each operator again. Since 2024-12, every
|
|
439
|
+
operator served the vocabulary text, for example "Selects records based on the After
|
|
440
|
+
vocabulary." for `after`. The diagram editor showed this text to users. Now an operator
|
|
441
|
+
serves the text that it sets with `desc`, and an operator that sets `no_desc` serves an
|
|
442
|
+
empty text. A vocabulary operator, which sets no `desc`, still serves "Selects records
|
|
443
|
+
based on the <name> vocabulary." The SQL is unchanged.
|
|
444
|
+
- In `ConceptQL.metadata`, each vocabulary operator serves `predominant_domains` as a flat
|
|
445
|
+
list, for example `["condition_occurrence"]`, as the other operators do. Before, it served
|
|
446
|
+
the list inside another list (`[["condition_occurrence"]]`). The SQL is unchanged.
|
|
447
|
+
- The diagram editor shows the `desc` of each operator to its users, so 14 texts are
|
|
448
|
+
rewritten to say what the operator does. `dedup` served the text of `count`. `numeric` read
|
|
449
|
+
like a code comment. `provenance` now says that it needs at least one keyword, that `*`
|
|
450
|
+
matches any provenance, and that `<file keyword>:<code keyword>` matches the file AND the
|
|
451
|
+
code. `information_periods` claimed that it takes an upstream stream, which it refuses.
|
|
452
|
+
`concurrent_within` said that the date ranges overlap, but the other record must lie
|
|
453
|
+
inside the record's date range. `provider_filter` now names its required `roles` option.
|
|
454
|
+
`death`, `person` and `sum` now describe all of their inputs, and `occurrence`, `recall`,
|
|
455
|
+
`hospice`, `hospitalization` and `snf` were fixed for typos or a missing final period.
|
|
456
|
+
Every served `desc` now ends with a period. Also, every operator serves
|
|
457
|
+
`predominant_domains` as strings. Before, a vocabulary without a domain and an operator that
|
|
458
|
+
declares its domains, such as `death`, served symbols (`:condition_occurrence`, `:death`)
|
|
459
|
+
to Ruby callers. The JSON and the SQL are unchanged.
|
|
460
|
+
|
|
7
461
|
## [0.5.0] - 2026-10-29
|
|
8
462
|
|
|
9
463
|
### Removed
|