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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +454 -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 +207 -67
  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 -1
  10. data/lib/conceptql/data_model/base.rb +2 -4
  11. data/lib/conceptql/data_model/gdm.rb +3 -1
  12. data/lib/conceptql/database.rb +7 -2
  13. data/lib/conceptql/date_adjuster.rb +12 -5
  14. data/lib/conceptql/lexicon/lexicon_gdm.rb +4 -1
  15. data/lib/conceptql/lexicon/lexicon_ohdsi.rb +19 -1
  16. data/lib/conceptql/lexicon/lexicon_strategy.rb +35 -1
  17. data/lib/conceptql/lexicon.rb +210 -2
  18. data/lib/conceptql/nodifier.rb +7 -1
  19. data/lib/conceptql/one_query_counts.rb +120 -0
  20. data/lib/conceptql/operators/count.rb +4 -0
  21. data/lib/conceptql/operators/death.rb +1 -1
  22. data/lib/conceptql/operators/dedup.rb +1 -1
  23. data/lib/conceptql/operators/episode.rb +10 -2
  24. data/lib/conceptql/operators/ethnicity.rb +0 -4
  25. data/lib/conceptql/operators/except.rb +5 -0
  26. data/lib/conceptql/operators/filter.rb +12 -2
  27. data/lib/conceptql/operators/hospice.rb +1 -1
  28. data/lib/conceptql/operators/hospitalization.rb +1 -1
  29. data/lib/conceptql/operators/information_periods.rb +4 -1
  30. data/lib/conceptql/operators/invalid.rb +7 -1
  31. data/lib/conceptql/operators/multiple_vocabularies.rb +19 -6
  32. data/lib/conceptql/operators/numeric.rb +51 -14
  33. data/lib/conceptql/operators/occurrence.rb +16 -4
  34. data/lib/conceptql/operators/operator.rb +63 -19
  35. data/lib/conceptql/operators/person.rb +1 -1
  36. data/lib/conceptql/operators/person_filter.rb +5 -0
  37. data/lib/conceptql/operators/place_of_service_filter.rb +38 -10
  38. data/lib/conceptql/operators/provenance.rb +12 -2
  39. data/lib/conceptql/operators/provider_filter.rb +5 -1
  40. data/lib/conceptql/operators/race.rb +0 -4
  41. data/lib/conceptql/operators/recall.rb +1 -1
  42. data/lib/conceptql/operators/snf.rb +2 -2
  43. data/lib/conceptql/operators/sum.rb +46 -5
  44. data/lib/conceptql/operators/temporal_operator.rb +6 -2
  45. data/lib/conceptql/operators/trim_date.rb +52 -6
  46. data/lib/conceptql/operators/trim_date_end.rb +11 -1
  47. data/lib/conceptql/operators/trim_date_start.rb +11 -1
  48. data/lib/conceptql/operators/union.rb +45 -1
  49. data/lib/conceptql/operators/vocabulary.rb +7 -3
  50. data/lib/conceptql/paths.rb +10 -0
  51. data/lib/conceptql/query.rb +78 -24
  52. data/lib/conceptql/query_modifiers/gdm/provider_query_modifier.rb +7 -7
  53. data/lib/conceptql/rdbms/generic.rb +21 -1
  54. data/lib/conceptql/rdbms/spark.rb +12 -0
  55. data/lib/conceptql/scope.rb +187 -24
  56. data/lib/conceptql/version.rb +1 -1
  57. data/lib/conceptql/vocabularies/drg_systems.rb +118 -0
  58. data/lib/conceptql/vocabularies/dynamic_vocabularies.rb +10 -4
  59. data/lib/conceptql/vocabularies/entry.rb +15 -3
  60. data/lib/conceptql/vocabularies/formats.rb +59 -0
  61. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +6 -1
  62. data/lib/conceptql/window/table.rb +12 -3
  63. metadata +8 -4
  64. data/lib/conceptql/operators/concurrent_within.rb +0 -69
data/doc/spec.md CHANGED
@@ -33,7 +33,7 @@ GDM keeps the data in a small number of tables:
33
33
  - deaths
34
34
  - One row for each recorded death
35
35
 
36
- ConceptQL supports two variants of GDM. The data model `gdm` reads clinical codes from clinical_codes. The data model `gdm_wide` reads them from observations, a wide table that holds the same rows as clinical_codes with the columns of their contexts and collections joined on. ConceptQL looks up codes, such as ICD-9 412, in a vocabulary database to find their concept IDs.
36
+ ConceptQL supports two variants of GDM. The data model `gdm` reads clinical codes from clinical_codes. The data model `gdm_wide` reads them from observations, a wide table that holds the same rows as clinical_codes with the columns of their contexts and collections joined on. ConceptQL looks up codes, such as ICD-9 412, in a vocabulary database to find their concept IDs. When the data database is a real database, and neither its search path nor the database at LEXICON_URL has the vocabulary tables, ConceptQL raises an error instead of running, because without a vocabulary every code would match no rows.
37
37
 
38
38
  For instance, using ConceptQL we can take a statement that looks like this:
39
39
 
@@ -256,6 +256,43 @@ Selection operators are the parts of a ConceptQL query that search for specific
256
256
 
257
257
  There are _many_ selection operators. A list of currently implemented operators is available in Appendix A.
258
258
 
259
+ ### DRG Codes
260
+
261
+ CMS renumbered the Diagnosis-Related Group (DRG) codes on 2007-10-01, when the MS-DRG system replaced the CMS-DRG system. Most codes got a new meaning: DRG 205 meant a liver disorder in CMS-DRG and means other respiratory system diagnoses in MS-DRG. One vocabulary, DRG, holds the codes of both systems, and a clinical code does not record which meaning applied. So a statement names the DRG system, and ConceptQL selects by the code and by the start_date of the clinical code:
262
+
263
+ - `ms_drg` selects the DRG codes whose start_date is on or after 2007-10-01.
264
+ - `cms_drg` selects the DRG codes whose start_date is before 2007-10-01.
265
+
266
+ ```YAML
267
+ ---
268
+ - ms_drg
269
+ - '205'
270
+
271
+ ```
272
+
273
+ ![All MS-DRG 205 events: other respiratory system diagnoses with a major complication or comorbidity](spec/2f8919588f782c227a2d45b6579a2bf51059bf3a38e5a5524b3edb06e0be9900.png)
274
+
275
+ | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value |
276
+ | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ |
277
+ | 17 | 1365 | observations | procedure_occurrence | 2010-06-02 | 2010-06-06 | 205 |
278
+ | 68 | 18 | observations | procedure_occurrence | 2008-04-19 | 2008-04-23 | 205 |
279
+ | 95 | 603 | observations | procedure_occurrence | 2008-08-27 | 2008-09-01 | 205 |
280
+
281
+ ```YAML
282
+ ---
283
+ - cms_drg
284
+ - '205'
285
+
286
+ ```
287
+
288
+ ![All CMS-DRG 205 events (a liver disorder): none, because the test data starts in December 2007](spec/ba330009cc3750e3d781ff6f4a52b1a4e7c6e440b1f950b8e43419f10b1e56dc.png)
289
+
290
+ `No Results found.`
291
+
292
+ Both operators check codes as other vocabulary operators do: a DRG code has 3 digits, and a code that the DRG vocabulary does not have gets a warning. When the vocabulary records the system of each meaning (the OHDSI concept class MS-DRG or DRG), the code list of a statement names each code by its meaning in the operator's system. Otherwise it names the code by all of its meanings.
293
+
294
+ The `drg` operator is deprecated. It selects the DRG codes of both systems, whatever the date. It still runs, but annotation shows a warning on it, and a later release will make it an error.
295
+
259
296
  ## All Other Operators i.e. Mutation Operators
260
297
 
261
298
  Virtually all other operators add, remove, filter, or otherwise alter streams of results. They are discussed in this section.
@@ -269,7 +306,9 @@ Because streams represent sets of results, its makes sense to include a operator
269
306
  - Takes any number of upstream operators and aggregates their streams
270
307
  - Unions together streams with identical types
271
308
  - Think of streams with the same type flowing together into a single stream
272
- - Union does not remove duplicates: a result that arrives from two upstream operators can appear twice
309
+ - Union removes duplicates: a result that arrives from more than one upstream operator appears once
310
+ - Every stream is a set, so no operator passes along two identical results
311
+ - Two results are identical when they have the same value in every column, label included
273
312
  - Streams with the different types flow along together concurrently without interacting
274
313
  - It does not make sense to union, say, condition_occurrence results with procedure_occurrence results, so streams with different types won't mingle together, but will continue to flow downstream in parallel
275
314
 
@@ -353,9 +392,35 @@ Because streams represent sets of results, its makes sense to include a operator
353
392
  | 17 | 31480 | observations | condition_occurrence | 2009-05-21 | 2009-05-21 | 486 |
354
393
  | 17 | 53417 | observations | procedure_occurrence | 2008-04-05 | 2008-04-05 | 99231 |
355
394
 
395
+ ```YAML
396
+ ---
397
+ - union
398
+ - - first
399
+ - - icd9
400
+ - '412'
401
+ - - icd9
402
+ - '412'
403
+
404
+ ```
405
+
406
+ ![The first MI of each patient is also one of the patient's MIs, so it appears once: the union holds the same results as icd9 412 alone](spec/4e6081d437226f76213819b0626b91ce3ba4e340da55c7366bf61ffa276a9b2c.png)
407
+
408
+ | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value |
409
+ | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ |
410
+ | 11 | 82614 | observations | condition_occurrence | 2008-10-29 | 2008-10-29 | 412 |
411
+ | 17 | 27269 | observations | condition_occurrence | 2008-08-25 | 2008-08-25 | 412 |
412
+ | 17 | 46165 | observations | condition_occurrence | 2009-04-30 | 2009-04-30 | 412 |
413
+ | 38 | 40556 | observations | condition_occurrence | 2010-02-12 | 2010-02-12 | 412 |
414
+ | 54 | 74606 | observations | condition_occurrence | 2008-06-05 | 2008-06-05 | 412 |
415
+ | 60 | 986 | observations | condition_occurrence | 2009-07-19 | 2009-07-22 | 412 |
416
+ | 66 | 16171 | observations | condition_occurrence | 2009-07-25 | 2009-07-25 | 412 |
417
+ | 73 | 52843 | observations | condition_occurrence | 2008-11-16 | 2008-11-16 | 412 |
418
+ | 77 | 58165 | observations | condition_occurrence | 2010-10-06 | 2010-10-06 | 412 |
419
+ | 81 | 1405 | observations | condition_occurrence | 2009-01-28 | 2009-01-30 | 412 |
420
+
356
421
  ### Filter
357
422
 
358
- This operator takes two sets of incoming streams, a left-hand stream and a right-hand stream. It passes along each left-hand result that also appears in the right-hand stream. Two results are the same when they have the same person_id, criterion_id, criterion_domain, start_date, and end_date. The operator discards all results in the right-hand stream. Use filter to keep only the results that two streams have in common, as an intersection would.
423
+ This operator takes two sets of incoming streams, a left-hand stream and a right-hand stream. It passes along each left-hand result that also appears in the right-hand stream. Two results are the same when they have the same person_id, criterion_id, criterion_table, criterion_domain, start_date, and end_date. A criterion_id identifies a result only within its criterion_table, so results from two tables never match, even when their ids are equal. The operator discards all results in the right-hand stream. Use filter to keep only the results that two streams have in common, as an intersection would.
359
424
 
360
425
  ```YAML
361
426
  ---
@@ -524,6 +589,62 @@ And just to show how multiple streams behave:
524
589
  | 66 | 16171 | observations | condition_occurrence | 2009-07-25 | 2009-07-25 | 412 |
525
590
  | 73 | 52843 | observations | condition_occurrence | 2008-11-16 | 2008-11-16 | 412 |
526
591
 
592
+ ## Filtering by Provenance
593
+
594
+ ### provenance
595
+
596
+ `provenance` passes along the events of its upstream stream that come from a given kind of source record. Each event has two provenances. Its file provenance tells which kind of file the record came from, such as `inpatient`, `outpatient` or `carrier_claim`. Its code provenance tells the position of the code in the record, such as `primary`, `first` or `admitting`. Each argument of `provenance` is a keyword from the file or the code provenance vocabulary, and the operator keeps an event when its provenance is that keyword's concept or one of its descendants.
597
+
598
+ A plain keyword, such as `inpatient` or `primary_or_first`, keeps an event when either provenance matches it. A keyword of the form `<file keyword>:<code keyword>` keeps an event when its file provenance matches the file keyword AND its code provenance matches the code keyword. The left half must be a file provenance keyword and the right half a code provenance keyword. Any other keyword with a `:` is an error.
599
+
600
+ When `provenance` has several keywords, it keeps the events that match any one of them. So `["inpatient:primary", "outpatient:first"]` keeps the inpatient events with a primary code and the outpatient events with a first code.
601
+
602
+ ```YAML
603
+ ---
604
+ - provenance
605
+ - - icd9
606
+ - '250.00'
607
+ - inpatient:primary_or_first
608
+
609
+ ```
610
+
611
+ ![Diabetes diagnoses that are the primary or first code of an inpatient record](spec/14942bd4e7713897ad9aa646c71cc348d5690cccafadd50e4a7dcd65b9a81061.png)
612
+
613
+ | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value |
614
+ | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ |
615
+ | 37 | 310 | observations | condition_occurrence | 2009-01-19 | 2009-01-20 | 25000 |
616
+ | 68 | 19 | observations | condition_occurrence | 2008-04-19 | 2008-04-23 | 25000 |
617
+ | 68 | 1596 | observations | condition_occurrence | 2008-11-10 | 2008-11-11 | 25000 |
618
+ | 131 | 676 | observations | condition_occurrence | 2008-03-21 | 2008-03-24 | 25000 |
619
+ | 207 | 106 | observations | condition_occurrence | 2010-04-28 | 2010-05-02 | 25000 |
620
+ | 237 | 918 | observations | condition_occurrence | 2010-07-16 | 2010-07-19 | 25000 |
621
+
622
+ The keyword `*` keeps an event that has any file or code provenance. It drops only the events whose two provenances are both empty. Several keywords are ORed, so `*` with other keywords keeps the same events as `*` alone. A `*` in a `<file keyword>:<code keyword>` keyword is an error. `provenance` needs at least one keyword: with none, it is the error `no provenance keywords; use "*" for any provenance`.
623
+
624
+ ```YAML
625
+ ---
626
+ - provenance
627
+ - - icd9
628
+ - '250.00'
629
+ - "*"
630
+
631
+ ```
632
+
633
+ ![Diabetes diagnoses with any provenance](spec/8614aacee51d8264bc6c61b5e202aa9c0c9318fd66c2ff254cdb45456bdda9fc.png)
634
+
635
+ | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value |
636
+ | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ |
637
+ | 2 | 89752 | observations | condition_occurrence | 2009-05-11 | 2009-05-11 | 25000 |
638
+ | 2 | 57897 | observations | condition_occurrence | 2009-05-16 | 2009-05-16 | 25000 |
639
+ | 2 | 76985 | observations | condition_occurrence | 2009-07-31 | 2009-07-31 | 25000 |
640
+ | 3 | 93400 | observations | condition_occurrence | 2009-11-17 | 2009-11-17 | 25000 |
641
+ | 5 | 89717 | observations | condition_occurrence | 2009-06-28 | 2009-06-28 | 25000 |
642
+ | 7 | 15942 | observations | condition_occurrence | 2008-04-23 | 2008-04-23 | 25000 |
643
+ | 7 | 69706 | observations | condition_occurrence | 2008-05-12 | 2008-05-12 | 25000 |
644
+ | 7 | 38732 | observations | condition_occurrence | 2008-06-09 | 2008-06-09 | 25000 |
645
+ | 7 | 38733 | observations | condition_occurrence | 2008-06-09 | 2008-06-09 | 25000 |
646
+ | 7 | 70454 | observations | condition_occurrence | 2008-10-02 | 2008-10-02 | 25000 |
647
+
527
648
  ## Co-reported Events
528
649
 
529
650
  ### co_reported
@@ -856,16 +977,25 @@ There are situations when the date columns associated with a result should have
856
977
 
857
978
  - Takes 2 arguments
858
979
  - First argument is the stream on which to operate
859
- - Second argument is a hash with two keys: [:start, :end] each with a value in the following format: "(-?\\d+[dmy])+"
980
+ - Second argument is a hash with two keys: [:start, :end] each with a value in the following format: "[res]\*([-+]?\\d\*[dwmy])+" or "[res]\*[-+]?\\d+"
981
+ - d is days, w is weeks (7 days), m is months and y is years
982
+ - A unit without a number means 1 of that unit
860
983
  - Both start and end must be defined, even if you are only adjusting one of the dates
861
984
  - Some examples
862
985
  - 30d => 30 days
863
986
  - 20 => 20 days
864
987
  - d => 1 day
988
+ - 2w => 2 weeks (14 days)
865
989
  - 1y => 1 year
866
990
  - -1m => -1 month
867
991
  - 10d3m => 3 months and 10 days
868
992
  - -2y10m-3d => -2 years, +10 months, -3 days
993
+ - An optional prefix of the letters r, e and s comes before the amounts
994
+ - r reverses the adjustment: each amount counts back instead of forward, so R365d means 365 days before the date and R-1m means 1 month after it
995
+ - e adjusts from the result's end_date, so E30d means end_date + 30 days
996
+ - s adjusts from the result's start_date, so S7d means start_date + 7 days
997
+ - Without e or s, the start value adjusts start_date and the end value adjusts end_date
998
+ - A bare number means days, also after an r, e or s prefix: R365, E30 and S7 mean the same as R365d, E30d and S7d
869
999
  - The start or end value can also be '', '0', or nil
870
1000
  - This will leave the date unaffected
871
1001
  - The start or end value can also be the string 'start' or 'end'
@@ -1036,6 +1166,27 @@ Person streams carry a patient's date of birth in their date columns. This makes
1036
1166
  | 152 | 82988 | observations | condition_occurrence | 2008-08-16 | 2008-08-16 | 412 |
1037
1167
  | 152 | 84298 | observations | condition_occurrence | 2010-11-22 | 2010-11-22 | 412 |
1038
1168
 
1169
+ ### trim_date_end and trim_date_start
1170
+
1171
+ - trim_date_end takes a left stream and a right stream. For each person, it trims the end_date of each left record to the earliest start_date of the person's right records.
1172
+ - A left record whose start_date comes after that right start_date is discarded.
1173
+ - A left record passes through unchanged when its person has no right records, or when that right start_date comes after its end_date.
1174
+ - trim_date_start trims the other end. For each person, it trims the start_date of each left record to the latest end_date of the person's right records.
1175
+ - A left record whose end_date comes before that right end_date is discarded.
1176
+ - A left record passes through unchanged when its person has no right records, or when that right end_date comes before its start_date.
1177
+ - With join_columns, the person's right records are the right records of the person with the same join_columns values as the left record.
1178
+ - With compare_all: true, trim_date_end and trim_date_start compare each left record with every right record of the person (and of the join_columns), not only with the person's first or last right record, and return each left record once.
1179
+ - trim_date_end trims its end_date to the earliest right start_date on or after its start_date.
1180
+ - trim_date_start trims its start_date to the latest right end_date on or before its end_date.
1181
+ - A left record is discarded only when it has right records and none of them qualifies.
1182
+
1183
+ ### Window tables
1184
+
1185
+ - The `window` statement runs a statement inside windows: `["window", <statement>, {"window_table": <window table>}]`.
1186
+ - The window table has the columns person_id, start_date and end_date. It is the name of a table or a ConceptQL statement.
1187
+ - Each selection operator keeps only the results whose person has a window and whose start_date falls in that window. The column window_id of a result identifies its window.
1188
+ - If the window table is a ConceptQL statement with errors, annotate reports them under window_table and counts nothing. Running the statement raises InvalidStatementError.
1189
+
1039
1190
  ## Type Conversion
1040
1191
 
1041
1192
  There are situations where it is appropriate to convert the type of a stream of results into a different type. In programmer parlance, we say "typecasting" or "casting", which is the terminology we'll use here. A good analogy and mnemonic for casting is to think of taking a piece of metal, say a candle holder, melting it down, and recasting it into, say, a lamp. We'll do something similar with streams. We'll take, for example, a condition_occurrence stream and recast it into a stream of person.
@@ -1518,64 +1669,37 @@ With a `numeric` operator defined, we can use the `sum` operator to sum the valu
1518
1669
 
1519
1670
  ### Counting
1520
1671
 
1521
- The `count` operator groups identical results and stores the number of results in each group in the value_as_number column. Two results are identical only when all of their columns match, including criterion_id. So `count` does not count the events of each person. Casting a stream to person first does not help, because the cast yields one result for each person.
1672
+ The `count` operator is deprecated. It still runs, but annotation shows a warning on it, and a later release will make it an error. `count` groups identical results and stores the number of results in each group in the value_as_number column. Two results are identical only when all of their columns match, including criterion_id. Every stream is a set, so `count` returns 1 for nearly every result. It does not count the events of each person.
1522
1673
 
1523
- To count the events of each person, set value_as_number to 1 with `numeric`, and then use `sum`:
1674
+ To count the events of each person, set value_as_number to 1 with `numeric`, and then use `sum` with its `group_by_person` option. `sum` gives one person result for each person, and value_as_number holds the number of events. Without `group_by_person`, `sum` puts the results of one person in several groups when they have different source_values, so the count of the person is split over several results.
1524
1675
 
1525
1676
  ```YAML
1526
1677
  ---
1527
1678
  - sum
1528
1679
  - - numeric
1529
1680
  - 1
1530
- - - icd9
1531
- - '486'
1532
-
1533
- ```
1534
-
1535
- ![Count the number of times each person had pneumonia](spec/385ede8312e323fa1ae2337c303c8e89f252ebcb8ba229961fa3af1a3caec886.png)
1536
-
1537
- | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value | value_as_number |
1538
- | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ | --------------- |
1539
- | 14 | 0 | observations | condition_occurrence | 2008-09-12 | 2009-04-05 | 486 | 2.0 |
1540
- | 17 | 0 | observations | condition_occurrence | 2009-05-21 | 2009-05-21 | 486 | 1.0 |
1541
- | 19 | 0 | observations | condition_occurrence | 2008-02-02 | 2008-02-03 | 486 | 1.0 |
1542
- | 23 | 0 | observations | condition_occurrence | 2009-08-18 | 2010-02-17 | 486 | 3.0 |
1543
- | 25 | 0 | observations | condition_occurrence | 2009-07-08 | 2009-07-08 | 486 | 1.0 |
1544
- | 27 | 0 | observations | condition_occurrence | 2010-09-30 | 2010-09-30 | 486 | 1.0 |
1545
- | 28 | 0 | observations | condition_occurrence | 2009-09-12 | 2009-09-12 | 486 | 1.0 |
1546
- | 38 | 0 | observations | condition_occurrence | 2010-02-24 | 2010-02-24 | 486 | 3.0 |
1547
- | 44 | 0 | observations | condition_occurrence | 2009-09-07 | 2010-08-30 | 486 | 6.0 |
1548
- | 51 | 0 | observations | condition_occurrence | 2008-03-08 | 2008-10-18 | 486 | 2.0 |
1549
-
1550
- We could do dumb things like count the number of times a row shows up in a union:
1551
-
1552
- ```YAML
1553
- ---
1554
- - count
1555
- - - union
1556
1681
  - - icd9
1557
1682
  - '412'
1558
- - - provenance
1559
- - - icd9
1560
- - '412'
1561
- - inpatient
1683
+ - '414.01'
1684
+ - '250.00'
1685
+ - :group_by_person: true
1562
1686
 
1563
1687
  ```
1564
1688
 
1565
- ![All rows with a value of 2 would be rows that were both MI and from an inpatient record](spec/ecede6b94c97cf0b036a664e4df704d119be7c31607a1defe6eb77f294fd1393.png)
1566
-
1567
- | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value | value_as_number |
1568
- | --------- | ------------ | --------------- | -------------------- | ---------- | ---------- | ------------ | --------------- |
1569
- | 11 | 82614 | observations | condition_occurrence | 2008-10-29 | 2008-10-29 | 412 | 1 |
1570
- | 17 | 27269 | observations | condition_occurrence | 2008-08-25 | 2008-08-25 | 412 | 1 |
1571
- | 17 | 46165 | observations | condition_occurrence | 2009-04-30 | 2009-04-30 | 412 | 1 |
1572
- | 38 | 40556 | observations | condition_occurrence | 2010-02-12 | 2010-02-12 | 412 | 1 |
1573
- | 54 | 74606 | observations | condition_occurrence | 2008-06-05 | 2008-06-05 | 412 | 1 |
1574
- | 60 | 986 | observations | condition_occurrence | 2009-07-19 | 2009-07-22 | 412 | 2 |
1575
- | 66 | 16171 | observations | condition_occurrence | 2009-07-25 | 2009-07-25 | 412 | 1 |
1576
- | 73 | 52843 | observations | condition_occurrence | 2008-11-16 | 2008-11-16 | 412 | 1 |
1577
- | 77 | 58165 | observations | condition_occurrence | 2010-10-06 | 2010-10-06 | 412 | 1 |
1578
- | 81 | 1405 | observations | condition_occurrence | 2009-01-28 | 2009-01-30 | 412 | 2 |
1689
+ ![Count the number of times each person had a heart attack or diabetes](spec/8324a33ed37a82d29fb36ba9ed6fe84b445b7589af1c0c016813d2d98b2c6d75.png)
1690
+
1691
+ | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value | value_as_number |
1692
+ | --------- | ------------ | --------------- | ---------------- | ---------- | ---------- | ------------ | --------------- |
1693
+ | 2 | 0 | patients | person | 2009-05-11 | 2009-07-31 | | 3.0 |
1694
+ | 3 | 0 | patients | person | 2009-11-17 | 2009-11-17 | | 1.0 |
1695
+ | 5 | 0 | patients | person | 2009-06-28 | 2009-06-28 | | 1.0 |
1696
+ | 6 | 0 | patients | person | 2010-09-26 | 2010-09-26 | | 1.0 |
1697
+ | 7 | 0 | patients | person | 2008-04-23 | 2010-07-24 | | 30.0 |
1698
+ | 8 | 0 | patients | person | 2008-10-17 | 2009-07-16 | | 3.0 |
1699
+ | 9 | 0 | patients | person | 2010-08-01 | 2010-09-12 | | 2.0 |
1700
+ | 11 | 0 | patients | person | 2008-06-17 | 2009-11-12 | | 4.0 |
1701
+ | 12 | 0 | patients | person | 2009-04-20 | 2009-05-21 | | 3.0 |
1702
+ | 13 | 0 | patients | person | 2008-03-05 | 2010-09-08 | | 9.0 |
1579
1703
 
1580
1704
  #### Numeric Value Comparison
1581
1705
 
@@ -1593,11 +1717,12 @@ The `numeric_filter` operator takes one stream. It passes along the results whos
1593
1717
  - 1
1594
1718
  - - icd9
1595
1719
  - '412'
1720
+ - :group_by_person: true
1596
1721
  - :greater_than_or_equal_to: 2
1597
1722
 
1598
1723
  ```
1599
1724
 
1600
- ![People with more than 1 MI](spec/0d8546d33c77d25b522ae23b74c87bf9ee77f5564c6de688af8a2b270b615cfb.png)
1725
+ ![People with more than 1 MI](spec/6613c788b33483210fbc0c40fdcab5942bd3fca204c8a188ae176d51435041b0.png)
1601
1726
 
1602
1727
  | person_id | criterion_id | criterion_table | criterion_domain | start_date | end_date | source_value | value_as_number |
1603
1728
  | --------- | ------------ | --------------- | ---------------- | ---------- | ---------- | ---------------- | --------------- |
@@ -1646,27 +1771,42 @@ Numeric doesn't have to take a stream. If it doesn't have a stream as an argumen
1646
1771
  - Sets start_date to the earliest start_date in the group
1647
1772
  - Sets the end_date to the most recent end_date in the group
1648
1773
  - Sets criterion_id to 0 since there is no particular single row that the result refers to anymore
1774
+ - Takes one option, `group_by_person`, which is true or false. When it is true, `sum` puts all of the results of each person in one group, whatever their type and source_value. It gives one person result for each person:
1775
+ - person_id is the person
1776
+ - criterion_domain is person and criterion_table is the person table, as a cast to `person` gives them
1777
+ - criterion_id is 0
1778
+ - start_date is the earliest start_date and end_date is the most recent end_date of the person's results
1779
+ - value_as_number is the sum of the value_as_number column of the person's results
1780
+ - The other columns, such as source_value, are NULL
1781
+ - In a window table, `sum` gives one result for each person and window
1649
1782
 
1650
1783
  ## Appendix A - Selection Operators
1651
1784
 
1652
1785
  ConceptQL creates a vocabulary operator for each vocabulary in its vocabulary list (`config/vocabularies.csv`) and in its vocabulary database. This table lists the most common selection operators.
1653
1786
 
1654
- | Operator Name | Stream Type | Arguments | Returns |
1655
- | ------------------- | -------------------- | ---------------------------------------- | -------------------------------------------------------------- |
1656
- | cpt | procedure_occurrence | 1 or more CPT codes | All clinical codes that match any of the CPT codes |
1657
- | icd9 | condition_occurrence | 1 or more ICD-9-CM diagnosis codes | All clinical codes that match any of the ICD-9 codes |
1658
- | icd9_procedure | procedure_occurrence | 1 or more ICD-9-CM procedure codes | All clinical codes that match any of the ICD-9 procedure codes |
1659
- | icd10cm | condition_occurrence | 1 or more ICD-10-CM codes | All clinical codes that match any of the ICD-10-CM codes |
1660
- | hcpcs | procedure_occurrence | 1 or more HCPCS codes | All clinical codes that match any of the HCPCS codes |
1661
- | loinc | observation | 1 or more LOINC codes | All clinical codes that match any of the LOINC codes |
1662
- | ndc | drug_exposure | 1 or more NDC codes | All clinical codes that match any of the NDC codes |
1663
- | rxnorm | drug_exposure | 1 or more RxNorm codes | All clinical codes that match any of the RxNorm codes |
1664
- | snomed | condition_occurrence | 1 or more SNOMED codes | All clinical codes that match any of the SNOMED codes |
1665
- | gender | person | 1 or more of Male, Female, and Unknown | All patients whose gender matches any of the arguments |
1666
- | race | person | 1 or more race names or race concept IDs | All patients whose race matches any of the arguments |
1667
- | death | death | None | All death records |
1668
- | information_periods | observation_period | None | All information periods |
1669
- | date_range | person | A start date and an end date | One result for each patient, with the given dates |
1787
+ | Operator Name | Stream Type | Arguments | Returns |
1788
+ | ------------------- | -------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
1789
+ | cpt | procedure_occurrence | 1 or more CPT codes | All clinical codes that match any of the CPT codes |
1790
+ | icd9 | condition_occurrence | 1 or more ICD-9-CM diagnosis codes | All clinical codes that match any of the ICD-9 codes |
1791
+ | icd9_procedure | procedure_occurrence | 1 or more ICD-9-CM procedure codes | All clinical codes that match any of the ICD-9 procedure codes |
1792
+ | icd10cm | condition_occurrence | 1 or more ICD-10-CM codes | All clinical codes that match any of the ICD-10-CM codes |
1793
+ | hcpcs | procedure_occurrence | 1 or more HCPCS codes | All clinical codes that match any of the HCPCS codes |
1794
+ | ms_drg | procedure_occurrence | 1 or more DRG codes | All clinical codes from 2007-10-01 that match any of the DRG codes |
1795
+ | cms_drg | procedure_occurrence | 1 or more DRG codes | All clinical codes before 2007-10-01 that match any of the DRG codes |
1796
+ | loinc | observation | 1 or more LOINC codes | All clinical codes that match any of the LOINC codes |
1797
+ | ndc | drug_exposure | 1 or more NDC codes | All clinical codes that match any of the NDC codes |
1798
+ | rxnorm | drug_exposure | 1 or more RxNorm codes | All clinical codes that match any of the RxNorm codes |
1799
+ | snomed | condition_occurrence | 1 or more SNOMED codes | All clinical codes that match any of the SNOMED codes |
1800
+ | gender | person | 1 or more of Male, Female, and Unknown | All patients whose gender matches any of the arguments |
1801
+ | race | person | 1 or more race names or race concept IDs | All patients whose race matches any of the arguments |
1802
+ | death | death | None | All death records |
1803
+ | information_periods | observation_period | None | All information periods |
1804
+ | date_range | person | A start date and an end date | One result for each patient, with the given dates |
1805
+ | hospitalization | condition_occurrence | None | All inpatient admissions |
1806
+ | hospice | condition_occurrence | None | All hospice admissions |
1807
+ | snf | condition_occurrence | None | All skilled nursing facility admissions |
1808
+
1809
+ hospitalization, hospice and snf select admissions. On gdm, they read collections and admission_details. On gdm_wide, they read observations, but their results still have criterion_table collections. Their results have criterion_domain condition_occurrence, because GDM has no visit or admission domain. primary_concept_id, source_value and source_vocabulary_id hold one code of the admission that has primary provenance. That code is usually a diagnosis, but it can be a procedure or a DRG. When the admission has no code with primary provenance, these three columns are NULL. Because the results have the condition_occurrence type, union combines them with the results of condition operators such as icd9 into one stream.
1670
1810
 
1671
1811
  ## Appendix B - Algorithm Showcase
1672
1812
 
@@ -38,6 +38,15 @@ module ConceptQL
38
38
  @conceptql_spec_id = value
39
39
  end
40
40
 
41
+ # Marks an operator as deprecated. It still runs. Operator#validate adds
42
+ # the message to its annotation as a warning, and its metadata says
43
+ # deprecated: true. A subclass does not inherit the mark.
44
+ def deprecated(message = nil)
45
+ return @deprecated unless message
46
+
47
+ @deprecated = message
48
+ end
49
+
41
50
  def predominant_domains(*values)
42
51
  return @predominant_domains if values.empty?
43
52
 
@@ -150,11 +159,12 @@ module ConceptQL
150
159
  max_upstreams: @max_upstreams || 0,
151
160
  arguments: @arguments || [],
152
161
  options: process_options(@options),
153
- predominant_domains: @domains || @predominant_domains || [],
162
+ # Strings, as JSON gives them: domains (for example death) are symbols
163
+ predominant_domains: (@domains || @predominant_domains || []).map(&:to_s),
154
164
  desc: get_desc,
155
165
  categories: @categories || [],
156
166
  basic_type: @basic_type,
157
- deprecated: nil, # kept for metadata consumers; no operator is deprecated
167
+ deprecated: (true if deprecated), # nil for an operator that is not deprecated
158
168
  arity: arity,
159
169
  aliases: @aliases,
160
170
  spec_id: spec_id
@@ -176,8 +186,10 @@ module ConceptQL
176
186
  end
177
187
  end
178
188
 
189
+ # The desc that the operator set (no_desc sets ""), else the standard
190
+ # vocabulary text. A subclass does not inherit its parent's desc.
179
191
  def get_desc
180
- @get_desc ||= standard_description
192
+ @desc || standard_description
181
193
  end
182
194
 
183
195
  def process_options(options)
@@ -1,26 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConceptQL
4
+ # Provenance keywords select records by their file provenance
5
+ # (file_provenance_type) and their code provenance (code_provenance_type).
6
+ #
7
+ # A plain keyword, such as "inpatient" or "primary", names a concept in the
8
+ # file or the code provenance vocabulary. It keeps a record when either
9
+ # column is in the descendants of that concept.
10
+ #
11
+ # A keyword "<file keyword>:<code keyword>", such as
12
+ # "inpatient:primary_or_first", keeps a record when its file provenance is
13
+ # in the descendants of the file keyword AND its code provenance is in the
14
+ # descendants of the code keyword.
15
+ #
16
+ # Several keywords are ORed: a record is kept when any keyword keeps it.
17
+ #
18
+ # The keyword "*" keeps a record when either column is any provenance
19
+ # concept. Because keywords are ORed, "*" with other keywords is "*".
4
20
  module Provenanceable
21
+ PAIR_SEPARATOR = ":"
22
+ ANY = "*"
23
+
5
24
  def self.included(base)
6
25
  base.require_column :file_provenance_type
7
26
  base.require_column :code_provenance_type
8
27
  end
9
28
 
10
- def prov_of(db, ancestors)
11
- dm.descendants_of(db,
12
- dm.concept_ids(db, [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten,
13
- ancestors))
29
+ # No keyword at all is a validation error of Provenance. It still builds
30
+ # the condition of "*", as an invalid pair still builds a condition.
31
+ def build_where_from_codes(db, codes)
32
+ return plain_condition(db, []) if codes.empty? || codes.include?(ANY)
33
+
34
+ pairs, plain = codes.partition { |code| pair_keyword?(code) }
35
+ conditions = pairs.map { |pair| pair_condition(db, pair) }
36
+ conditions.unshift(plain_condition(db, plain)) if plain.any?
37
+ return conditions.first if conditions.one?
38
+
39
+ Sequel.|(*conditions)
14
40
  end
15
41
 
16
- def build_where_from_codes(db, codes)
42
+ # The keywords as written that name no provenance concept. A pair is
43
+ # recognized only when it has one separator, a file keyword on the left
44
+ # and a code keyword on the right. Case is compared exactly, as the lookup
45
+ # compares it: the provenance vocabularies have no vocabulary_formats row
46
+ # that declares case-insensitivity (seed conceptql-oji ruling "b"). So
47
+ # "Inpatient" names no concept, and the concept codes are in lower case.
48
+ # "*" is known. It is not a half of a pair.
49
+ def find_bad_keywords(db, codes)
50
+ pairs, plain = (codes - [ ANY ]).partition { |code| pair_keyword?(code) }
51
+ known = known_keywords(db, all_provenance_types_vocab)
52
+ bad = plain.reject { |code| known.include?(code.to_s) }
53
+ bad += pairs.reject { |pair| known_pair?(db, pair) }
54
+ codes & bad
55
+ end
56
+
57
+ private
58
+
59
+ def pair_keyword?(code)
60
+ code.to_s.include?(PAIR_SEPARATOR)
61
+ end
62
+
63
+ def known_pair?(db, pair)
64
+ file, code, *rest = pair.split(PAIR_SEPARATOR, -1)
65
+ rest.empty? &&
66
+ known_keywords(db, dm.file_provenance_types_vocab).include?(file) &&
67
+ known_keywords(db, dm.code_provenance_types_vocab).include?(code)
68
+ end
69
+
70
+ # The concept codes of the vocabularies.
71
+ def known_keywords(db, vocabularies)
72
+ dm.concept_codes(db, vocabularies)
73
+ end
74
+
75
+ def all_provenance_types_vocab
76
+ [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten
77
+ end
78
+
79
+ def prov_of(db, vocabularies, ancestors)
80
+ dm.descendants_of(db, dm.concept_ids(db, vocabularies, ancestors))
81
+ end
82
+
83
+ def plain_condition(db, codes)
17
84
  Sequel.|(
18
- { file_provenance_type: prov_of(db, codes) }, { code_provenance_type: prov_of(db, codes) }
85
+ { file_provenance_type: prov_of(db, all_provenance_types_vocab, codes) },
86
+ { code_provenance_type: prov_of(db, all_provenance_types_vocab, codes) }
19
87
  )
20
88
  end
21
89
 
22
- def find_bad_keywords(db, codes)
23
- codes - dm.concept_codes(db, [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten)
90
+ # An invalid pair (validation reports it) still builds a condition. A
91
+ # half with no concept matches nothing, so the pair keeps no record.
92
+ def pair_condition(db, pair)
93
+ file, code = pair.split(PAIR_SEPARATOR, 2)
94
+ Sequel.&(
95
+ { file_provenance_type: prov_of(db, dm.file_provenance_types_vocab, [ file ]) },
96
+ { code_provenance_type: prov_of(db, dm.code_provenance_types_vocab, [ code ]) }
97
+ )
24
98
  end
25
99
  end
26
100
  end
@@ -41,13 +41,17 @@ module ConceptQL
41
41
  # With the Database's share_utilization option, the list is read from a
42
42
  # table that every statement on the Database shares. The WITH entry only
43
43
  # carries the list to Scope#with_ctes, which takes it out of the SQL and
44
- # creates the table when the query first reads rows.
44
+ # creates the table when the query first reads rows. With
45
+ # CONCEPTQL_AVOID_CTES, with_ctes extracts nothing, so the scope keeps
46
+ # the table instead.
45
47
  def evaluate(db)
46
48
  cdb = nodifier.cdb
47
49
  return super unless cdb.opts[:share_utilization]
48
50
 
49
51
  list = select_it(query(db))
50
52
  name = cdb.shared_utilization_table(list, scope)
53
+ return scope.add_shared_table(db, name, list) if ConceptQL.avoid_ctes?
54
+
51
55
  db[name].with(name, list, Scope::SHARED_TABLE => true)
52
56
  end
53
57
 
@@ -19,6 +19,7 @@ module ConceptQL
19
19
  :concepts_by_name,
20
20
  :concepts_ds,
21
21
  :concepts_to_codes,
22
+ :concepts_to_codes_in_class,
22
23
  :descendants_of,
23
24
  :known_codes,
24
25
  :related_concept_ids
@@ -258,10 +259,7 @@ module ConceptQL
258
259
  end
259
260
 
260
261
  def table_columns(*tables)
261
- tables.map { |t| table_cols(t) }.flatten.tap do |o|
262
- p o
263
- p tables
264
- end
262
+ tables.flat_map { |t| table_cols(t) }
265
263
  end
266
264
 
267
265
  def table_source_value(table)
@@ -115,8 +115,10 @@ module ConceptQL
115
115
  :context_id
116
116
  end
117
117
 
118
+ # The schema of a data model does not change, so the answer is kept, as
119
+ # for start_date_columns and the other column lookups.
118
120
  def source_vocabulary_ids
119
- @source_vocabulary_ids = assign_column_to_table do |table, columns|
121
+ @source_vocabulary_ids ||= assign_column_to_table do |table, columns|
120
122
  next if %w[patients death].any? { |tn| table.to_s =~ /#{tn}/ }
121
123
 
122
124
  reggy = /#{table.to_s.split('_').first}_vocabulary_id/
@@ -95,8 +95,10 @@ module ConceptQL
95
95
  end
96
96
 
97
97
  # The LEXICON_URL database, or nil when it is unset. Lexicon tries the
98
- # data db first and, when neither has the vocabulary tables, falls back
99
- # to LexiconNoDB, so no database (and no sqlite3 gem) is needed here.
98
+ # data db first. When neither has the vocabulary tables, a real data db
99
+ # raises Lexicon::NoVocabularyError, and a db-less Database (db nil or a
100
+ # Sequel mock) falls back to LexiconNoDB, so no database (and no sqlite3
101
+ # gem) is needed here.
100
102
  def make_lexicon_db
101
103
  return unless ENV["LEXICON_URL"]
102
104
 
@@ -126,6 +128,9 @@ module ConceptQL
126
128
  @source_tables.fetch(key) { db.table_exists?(table) && (@source_tables[key] = true) }
127
129
  end
128
130
 
131
+ # The Lexicon passed as lexicon:, or one built from the data db and the
132
+ # LEXICON_URL database. Database.new calls it, so a real data db without
133
+ # vocabulary tables raises Lexicon::NoVocabularyError there.
129
134
  def lexicon
130
135
  @lexicon ||= Lexicon.new(self.class.lexicon_db, db, cache: @opts[:cache_lookups])
131
136
  end