conceptql 0.6.0 → 0.7.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -0
  3. data/README.md +4 -2
  4. data/assets/diagram/diagram.css +331 -0
  5. data/assets/diagram/fonts/OFL.txt +93 -0
  6. data/assets/diagram/fonts/Roboto-400.ttf +0 -0
  7. data/assets/diagram/fonts/Roboto-700.ttf +0 -0
  8. data/assets/diagram/fonts/Roboto-Italic-400.ttf +0 -0
  9. data/assets/diagram/fonts/Roboto-Italic-Latin.woff2 +0 -0
  10. data/assets/diagram/fonts/Roboto-Normal-Latin.woff2 +0 -0
  11. data/assets/diagram/operators.svg +224 -0
  12. data/config/vocabularies.csv +85 -85
  13. data/docs/spec/README.md +2624 -0
  14. data/docs/spec/examples/after-operator.json +562 -0
  15. data/docs/spec/examples/any-overlap-operator.json +106 -0
  16. data/docs/spec/examples/before-operator.json +437 -0
  17. data/docs/spec/examples/co-reported-operator.json +168 -0
  18. data/docs/spec/examples/contains-operator.json +97 -0
  19. data/docs/spec/examples/date-range-operator.json +275 -0
  20. data/docs/spec/examples/death-operator.json +103 -0
  21. data/docs/spec/examples/during-operator.json +178 -0
  22. data/docs/spec/examples/episode-operator.json +80 -0
  23. data/docs/spec/examples/equal-operator.json +79 -0
  24. data/docs/spec/examples/ethnicity-operator.json +86 -0
  25. data/docs/spec/examples/except-operator.json +536 -0
  26. data/docs/spec/examples/filter-operator.json +218 -0
  27. data/docs/spec/examples/first-operator.json +148 -0
  28. data/docs/spec/examples/gender-operator.json +257 -0
  29. data/docs/spec/examples/hospice-operator.json +40 -0
  30. data/docs/spec/examples/hospitalization-operator.json +131 -0
  31. data/docs/spec/examples/information-periods-operator.json +131 -0
  32. data/docs/spec/examples/last-operator.json +148 -0
  33. data/docs/spec/examples/match-operator.json +186 -0
  34. data/docs/spec/examples/nth-occurrence-operator.json +300 -0
  35. data/docs/spec/examples/numeric-filter-operator.json +77 -0
  36. data/docs/spec/examples/one-in-two-out-operator.json +158 -0
  37. data/docs/spec/examples/person-filter-operator.json +919 -0
  38. data/docs/spec/examples/person-operator.json +274 -0
  39. data/docs/spec/examples/place-of-service-filter-operator.json +57 -0
  40. data/docs/spec/examples/provenance-operator.json +148 -0
  41. data/docs/spec/examples/provider-filter-operator.json +77 -0
  42. data/docs/spec/examples/race-operator.json +131 -0
  43. data/docs/spec/examples/recall-operator.json +289 -0
  44. data/docs/spec/examples/snf-operator.json +40 -0
  45. data/docs/spec/examples/time-window-operator.json +736 -0
  46. data/docs/spec/examples/trim-date-end-operator.json +201 -0
  47. data/docs/spec/examples/trim-date-start-operator.json +201 -0
  48. data/docs/spec/examples/union-operator.json +547 -0
  49. data/docs/spec/examples/vocabulary-operator.json +232 -0
  50. data/lib/conceptql/behaviors/metadatable.rb +68 -2
  51. data/lib/conceptql/behaviors/provenanceable.rb +4 -1
  52. data/lib/conceptql/behaviors/utilizable.rb +3 -0
  53. data/lib/conceptql/code_case_mismatch_error.rb +50 -0
  54. data/lib/conceptql/data_model/gdm.rb +23 -2
  55. data/lib/conceptql/diagram_renderer/app.rb +59 -0
  56. data/lib/conceptql/diagram_renderer/configuration.rb +87 -0
  57. data/lib/conceptql/diagram_renderer/diagram_data.rb +80 -0
  58. data/lib/conceptql/diagram_renderer/drawing.rb +255 -0
  59. data/lib/conceptql/diagram_renderer/geometry.rb +380 -0
  60. data/lib/conceptql/diagram_renderer/input_error.rb +11 -0
  61. data/lib/conceptql/diagram_renderer/js_format.rb +112 -0
  62. data/lib/conceptql/diagram_renderer/resvg.rb +120 -0
  63. data/lib/conceptql/diagram_renderer/sprite.rb +73 -0
  64. data/lib/conceptql/diagram_renderer/static.rb +70 -0
  65. data/lib/conceptql/diagram_renderer/svg.rb +73 -0
  66. data/lib/conceptql/diagram_renderer/tree_layout.rb +287 -0
  67. data/lib/conceptql/diagram_renderer.rb +82 -0
  68. data/lib/conceptql/lexicon/lexicon_strategy.rb +16 -0
  69. data/lib/conceptql/lexicon.rb +63 -11
  70. data/lib/conceptql/nodifier.rb +5 -3
  71. data/lib/conceptql/operators/after.rb +15 -1
  72. data/lib/conceptql/operators/any_overlap.rb +6 -1
  73. data/lib/conceptql/operators/before.rb +11 -1
  74. data/lib/conceptql/operators/binary_operator_operator.rb +12 -2
  75. data/lib/conceptql/operators/co_reported.rb +9 -4
  76. data/lib/conceptql/operators/contains.rb +6 -1
  77. data/lib/conceptql/operators/count.rb +2 -0
  78. data/lib/conceptql/operators/date_range.rb +15 -3
  79. data/lib/conceptql/operators/death.rb +5 -1
  80. data/lib/conceptql/operators/dedup.rb +2 -0
  81. data/lib/conceptql/operators/during.rb +6 -1
  82. data/lib/conceptql/operators/episode.rb +13 -2
  83. data/lib/conceptql/operators/equal.rb +11 -1
  84. data/lib/conceptql/operators/ethnicity.rb +19 -7
  85. data/lib/conceptql/operators/except.rb +10 -1
  86. data/lib/conceptql/operators/filter.rb +6 -1
  87. data/lib/conceptql/operators/first.rb +19 -9
  88. data/lib/conceptql/operators/from.rb +3 -0
  89. data/lib/conceptql/operators/gender.rb +10 -2
  90. data/lib/conceptql/operators/hospice.rb +7 -1
  91. data/lib/conceptql/operators/hospitalization.rb +6 -1
  92. data/lib/conceptql/operators/information_periods.rb +26 -7
  93. data/lib/conceptql/operators/invalid.rb +4 -0
  94. data/lib/conceptql/operators/last.rb +19 -9
  95. data/lib/conceptql/operators/match.rb +6 -1
  96. data/lib/conceptql/operators/multiple_vocabularies.rb +17 -3
  97. data/lib/conceptql/operators/numeric.rb +4 -0
  98. data/lib/conceptql/operators/numeric_filter.rb +13 -3
  99. data/lib/conceptql/operators/occurrence.rb +33 -12
  100. data/lib/conceptql/operators/one_in_two_out.rb +22 -5
  101. data/lib/conceptql/operators/operator.rb +8 -4
  102. data/lib/conceptql/operators/person.rb +5 -1
  103. data/lib/conceptql/operators/person_filter.rb +14 -1
  104. data/lib/conceptql/operators/place_of_service_filter.rb +14 -3
  105. data/lib/conceptql/operators/provenance.rb +17 -8
  106. data/lib/conceptql/operators/provider_filter.rb +18 -6
  107. data/lib/conceptql/operators/race.rb +14 -9
  108. data/lib/conceptql/operators/read.rb +11 -1
  109. data/lib/conceptql/operators/recall.rb +34 -2
  110. data/lib/conceptql/operators/snf.rb +7 -1
  111. data/lib/conceptql/operators/sum.rb +4 -0
  112. data/lib/conceptql/operators/temporal_operator.rb +4 -2
  113. data/lib/conceptql/operators/time_window.rb +27 -4
  114. data/lib/conceptql/operators/trim_date.rb +6 -14
  115. data/lib/conceptql/operators/trim_date_end.rb +9 -3
  116. data/lib/conceptql/operators/trim_date_start.rb +9 -3
  117. data/lib/conceptql/operators/union.rb +13 -1
  118. data/lib/conceptql/operators/vocabulary.rb +80 -6
  119. data/lib/conceptql/paths.rb +9 -1
  120. data/lib/conceptql/version.rb +1 -1
  121. data/lib/conceptql/vocabularies/drg_systems.rb +3 -2
  122. data/lib/conceptql/vocabularies/entry.rb +14 -11
  123. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +2 -1
  124. data/lib/conceptql.rb +3 -0
  125. metadata +75 -2
  126. data/doc/spec.md +0 -2091
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # A port of d3-hierarchy 3.1.2 (the version frontend/package-lock.json
6
+ # resolves): hierarchy() and tree(), the tidy tree layout of Buchheim, Jünger
7
+ # and Leipert (the linear-time version of Reingold-Tilford), as
8
+ # d3-hierarchy's src/tree.js implements it. Only the nodeSize mode is ported,
9
+ # because geometry.js uses only that mode.
10
+ #
11
+ # The arithmetic is the same as the JavaScript, in the same order and in
12
+ # Float (IEEE double, as JavaScript numbers), so the x values are the same
13
+ # to the last bit.
14
+ #
15
+ # root = TreeLayout.hierarchy(data) { |d| d["children"] }
16
+ # TreeLayout.new(node_size: [200, 140], separation: ->(_a, _b) { 1 }).call(root)
17
+ # root.descendants.map { [it.x, it.y] }
18
+ class TreeLayout
19
+ # eachBefore and eachAfter, for Node and TreeNode (in tree.js, TreeNode
20
+ # inherits them from Node). Both need only `children`.
21
+ module Traversal
22
+ # Pre-order, children left to right (node.eachBefore).
23
+ def each_before
24
+ nodes = [ self ]
25
+ while (node = nodes.pop)
26
+ yield node
27
+ node.children&.reverse_each { nodes.push(_1) }
28
+ end
29
+ self
30
+ end
31
+
32
+ # Post-order, children left to right (node.eachAfter).
33
+ def each_after
34
+ nodes = [ self ]
35
+ after = []
36
+ while (node = nodes.pop)
37
+ after.push(node)
38
+ node.children&.each { nodes.push(_1) }
39
+ end
40
+ yield after.pop until after.empty?
41
+ self
42
+ end
43
+ end
44
+
45
+ # A node of the hierarchy (d3-hierarchy Node). `children` is nil for a
46
+ # leaf, as in d3: an empty child list makes no children.
47
+ class Node
48
+ include Traversal
49
+
50
+ attr_accessor :data, :depth, :height, :parent, :children, :x, :y, :data_index
51
+
52
+ def initialize(data)
53
+ @data = data
54
+ @depth = 0
55
+ @height = 0
56
+ @parent = nil
57
+ @children = nil
58
+ end
59
+
60
+ # Breadth-first, children left to right (node[Symbol.iterator] and
61
+ # node.descendants()).
62
+ def descendants
63
+ found = []
64
+ level = [ self ]
65
+ until level.empty?
66
+ found.concat(level)
67
+ level = level.flat_map { _1.children || [] }
68
+ end
69
+ found
70
+ end
71
+
72
+ # Every parent-child link, in descendants order (node.links()).
73
+ def links
74
+ descendants.drop(1).map { Link.new(_1.parent, _1) }
75
+ end
76
+ end
77
+
78
+ Link = Struct.new(:source, :target)
79
+
80
+ # d3.hierarchy(data, children): the block returns a datum's children.
81
+ def self.hierarchy(data, &children)
82
+ root = Node.new(data)
83
+ nodes = [ root ]
84
+ while (node = nodes.pop)
85
+ childs = children.call(node.data)
86
+ next if childs.nil? || childs.empty?
87
+
88
+ node.children = childs.map { Node.new(_1) }
89
+ node.children.reverse_each do |child|
90
+ nodes.push(child)
91
+ child.parent = node
92
+ child.depth = node.depth + 1
93
+ end
94
+ end
95
+ root.each_before { compute_height(_1) }
96
+ end
97
+
98
+ # computeHeight in hierarchy/index.js.
99
+ def self.compute_height(node)
100
+ height = 0
101
+ loop do
102
+ node.height = height
103
+ node = node.parent
104
+ height += 1
105
+ break unless node && node.height < height
106
+ end
107
+ end
108
+ private_class_method :compute_height
109
+
110
+ # TreeNode in tree.js: the layout's working state for one node.
111
+ # z prelim, m mod, c change, s shift, t thread, a ancestor,
112
+ # ancestor_default A, i number among its siblings.
113
+ class TreeNode
114
+ include Traversal
115
+
116
+ attr_accessor :node, :parent, :children, :ancestor_default, :a, :z, :m, :c, :s, :t, :i
117
+
118
+ def initialize(node, i)
119
+ @node = node
120
+ @parent = nil
121
+ @children = nil
122
+ @ancestor_default = nil
123
+ @a = self
124
+ @z = 0.0
125
+ @m = 0.0
126
+ @c = 0.0
127
+ @s = 0.0
128
+ @t = nil
129
+ @i = i
130
+ end
131
+ end
132
+
133
+ def initialize(node_size:, separation:)
134
+ @dx = Float(node_size[0])
135
+ @dy = Float(node_size[1])
136
+ @separation = separation
137
+ end
138
+
139
+ # tree(root): sets x and y on every node of the hierarchy and returns it.
140
+ def call(root)
141
+ t = tree_root(root)
142
+
143
+ t.each_after { first_walk(_1) }
144
+ t.parent.m = -t.z
145
+ t.each_before { second_walk(_1) }
146
+
147
+ root.each_before { size_node(_1) }
148
+ root
149
+ end
150
+
151
+ private
152
+
153
+ # treeRoot in tree.js.
154
+ def tree_root(root)
155
+ tree = TreeNode.new(root, 0)
156
+ nodes = [ tree ]
157
+ while (node = nodes.pop)
158
+ children = node.node.children
159
+ next unless children
160
+
161
+ node.children = Array.new(children.length)
162
+ (children.length - 1).downto(0) do |i|
163
+ child = node.children[i] = TreeNode.new(children[i], i)
164
+ nodes.push(child)
165
+ child.parent = node
166
+ end
167
+ end
168
+
169
+ tree.parent = TreeNode.new(nil, 0)
170
+ tree.parent.children = [ tree ]
171
+ tree
172
+ end
173
+
174
+ # The separation function, called with hierarchy nodes; Float like a
175
+ # JavaScript number.
176
+ def separation(a, b) = Float(@separation.call(a, b))
177
+
178
+ # nextLeft in tree.js.
179
+ def next_left(v) = v.children ? v.children[0] : v.t
180
+
181
+ # nextRight in tree.js.
182
+ def next_right(v) = v.children ? v.children[-1] : v.t
183
+
184
+ # moveSubtree in tree.js.
185
+ def move_subtree(wm, wp, shift)
186
+ change = shift / (wp.i - wm.i)
187
+ wp.c -= change
188
+ wp.s += shift
189
+ wm.c += change
190
+ wp.z += shift
191
+ wp.m += shift
192
+ end
193
+
194
+ # executeShifts in tree.js.
195
+ def execute_shifts(v)
196
+ shift = 0.0
197
+ change = 0.0
198
+ v.children.reverse_each do |w|
199
+ w.z += shift
200
+ w.m += shift
201
+ change += w.c
202
+ shift += w.s + change
203
+ end
204
+ end
205
+
206
+ # nextAncestor in tree.js.
207
+ def next_ancestor(vim, v, ancestor)
208
+ vim.a.parent.equal?(v.parent) ? vim.a : ancestor
209
+ end
210
+
211
+ # firstWalk in tree.js.
212
+ def first_walk(v)
213
+ children = v.children
214
+ siblings = v.parent.children
215
+ w = v.i.zero? ? nil : siblings[v.i - 1]
216
+ if children
217
+ execute_shifts(v)
218
+ midpoint = (children[0].z + children[-1].z) / 2
219
+ if w
220
+ v.z = w.z + separation(v.node, w.node)
221
+ v.m = v.z - midpoint
222
+ else
223
+ v.z = midpoint
224
+ end
225
+ elsif w
226
+ v.z = w.z + separation(v.node, w.node)
227
+ end
228
+ v.parent.ancestor_default = apportion(v, w, v.parent.ancestor_default || siblings[0])
229
+ end
230
+
231
+ # secondWalk in tree.js.
232
+ def second_walk(v)
233
+ v.node.x = v.z + v.parent.m
234
+ v.m += v.parent.m
235
+ end
236
+
237
+ # apportion in tree.js.
238
+ def apportion(v, w, ancestor)
239
+ return ancestor unless w
240
+
241
+ vip = v
242
+ vop = v
243
+ vim = w
244
+ vom = vip.parent.children[0]
245
+ sip = vip.m
246
+ sop = vop.m
247
+ sim = vim.m
248
+ som = vom.m
249
+ loop do
250
+ vim = next_right(vim)
251
+ vip = next_left(vip)
252
+ break unless vim && vip
253
+
254
+ vom = next_left(vom)
255
+ vop = next_right(vop)
256
+ vop.a = v
257
+ shift = vim.z + sim - vip.z - sip + separation(vim.node, vip.node)
258
+ if shift > 0
259
+ move_subtree(next_ancestor(vim, v, ancestor), v, shift)
260
+ sip += shift
261
+ sop += shift
262
+ end
263
+ sim += vim.m
264
+ sip += vip.m
265
+ som += vom.m
266
+ sop += vop.m
267
+ end
268
+ if vim && !next_right(vop)
269
+ vop.t = vim
270
+ vop.m += sim - sop
271
+ end
272
+ if vip && !next_left(vom)
273
+ vom.t = vip
274
+ vom.m += sip - som
275
+ ancestor = v
276
+ end
277
+ ancestor
278
+ end
279
+
280
+ # sizeNode in tree.js.
281
+ def size_node(node)
282
+ node.x *= @dx
283
+ node.y = node.depth * @dy
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paths"
4
+
5
+ module ConceptQL
6
+ # The ConceptQL diagram renderer: a diagram tree (ConceptQL::Diagram.render)
7
+ # to a standalone static SVG (render_static), a PNG (png) or the in-app SVG
8
+ # (render_app). See docs/diagram_renderer.md. It uses the Ruby standard
9
+ # library and the resvg program only. lib/conceptql.rb autoloads it, so
10
+ # `require "conceptql"` loads none of it, and a plain Ruby process can load
11
+ # it alone:
12
+ #
13
+ # ruby -Ilib -e 'require "conceptql/diagram_renderer"; ConceptQL::DiagramRenderer::Geometry'
14
+ #
15
+ # It is a port of the JavaScript renderer of the Jigsaw diagram editor
16
+ # (frontend/lib/conceptql-diagram in the jigsaw-diagram-editor repository).
17
+ # The comments name the JavaScript files by their paths in that repository.
18
+ module DiagramRenderer
19
+ class << self
20
+ # The asset configuration (Configuration) that render_static and png use.
21
+ def configuration = @configuration ||= Configuration.new
22
+
23
+ # Replace the configuration with a new one that the block sets up.
24
+ def configure
25
+ config = Configuration.new
26
+ yield config
27
+ @configuration = config
28
+ end
29
+
30
+ # The standalone static SVG of a diagram tree (DiagramData.from takes the
31
+ # input), as text. show_counts labels each stream with its child's
32
+ # counts; show_options lists each node's options under its values.
33
+ def render_static(input, show_counts: false, show_options: false)
34
+ check_resvg_version
35
+ Static.new(configuration).render(input, show_counts:, show_options:)
36
+ end
37
+
38
+ # The static SVG rasterized by resvg, as PNG bytes, at the SVG's width and
39
+ # height.
40
+ def png(input, show_counts: false, show_options: false)
41
+ configuration.resvg.png(render_static(input, show_counts:, show_options:))
42
+ end
43
+
44
+ # The in-app SVG (App) of a diagram tree whose nodes have ids, as text:
45
+ # the svg.vis element of renderer.js, with click hooks and stable keys.
46
+ # Options: styles, icons, lock_nodes, summary, show_counts, show_options
47
+ # and css_transforms (see App#render and docs/diagram_renderer.md).
48
+ def render_app(tree, **options)
49
+ App.new(configuration).render(tree, **options)
50
+ end
51
+
52
+ private
53
+
54
+ # The first call in a process runs `resvg --version`. Another version
55
+ # than Resvg::VERSION can draw wrong diagrams, so it prints one warning
56
+ # to stderr and draws anyway. Later calls do not check again, even after
57
+ # configure. png calls render_static, so png checks too.
58
+ def check_resvg_version
59
+ return if @resvg_version_checked
60
+
61
+ @resvg_version_checked = true
62
+ found = configuration.resvg.version
63
+ return if found == Resvg::VERSION
64
+
65
+ warn "ConceptQL::DiagramRenderer: resvg #{found} is not the required version #{Resvg::VERSION}. The diagrams can be wrong."
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ require_relative "diagram_renderer/js_format"
72
+ require_relative "diagram_renderer/tree_layout"
73
+ require_relative "diagram_renderer/geometry"
74
+ require_relative "diagram_renderer/resvg"
75
+ require_relative "diagram_renderer/input_error"
76
+ require_relative "diagram_renderer/diagram_data"
77
+ require_relative "diagram_renderer/svg"
78
+ require_relative "diagram_renderer/sprite"
79
+ require_relative "diagram_renderer/configuration"
80
+ require_relative "diagram_renderer/drawing"
81
+ require_relative "diagram_renderer/static"
82
+ require_relative "diagram_renderer/app"
@@ -59,6 +59,22 @@ module ConceptQL
59
59
  "and #{(ids - declared).inspect} do not"
60
60
  end
61
61
 
62
+ # The [vocabulary_id, concept_code] pairs of the vocabularies' codes that
63
+ # equal one of the codes when case is ignored. Lexicon reads them to find
64
+ # a code given in another case than the stored one
65
+ # (CodeCaseMismatchError). A vocabulary that declares (?i:...) already
66
+ # compares without case, so it has none. A lexicon without a database has
67
+ # none either.
68
+ def case_variants(data_db, vocabulary_id, codes)
69
+ return [] if db.nil? || db_is_mock?(data_db) || case_insensitive?(vocabulary_id)
70
+
71
+ concepts_table(db)
72
+ .where(vocabulary_id: vocabulary_id)
73
+ .where(Sequel.function(:lower, :concept_code) => Array(codes).map { |code| code.to_s.downcase })
74
+ .distinct
75
+ .select_map(%i[vocabulary_id concept_code])
76
+ end
77
+
62
78
  def concepts_by_name(data_db, names = [])
63
79
  ds = concepts_table(data_db)
64
80
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
+ require_relative "code_case_mismatch_error"
4
5
  require_relative "lexicon/lexicon_gdm"
5
6
  require_relative "lexicon/lexicon_no_db"
6
7
  require_relative "lexicon/lexicon_ohdsi"
@@ -29,10 +30,8 @@ module ConceptQL
29
30
 
30
31
  def_delegators :@strategy,
31
32
  :strategy,
32
- :concepts,
33
33
  :concepts_table,
34
34
  :concepts_by_name,
35
- :concepts_ds,
36
35
  :vocabularies,
37
36
  :vocabularies_query
38
37
 
@@ -187,40 +186,77 @@ module ConceptQL
187
186
  @cache = {} if cache
188
187
  end
189
188
 
190
- (CACHED_LOOKUPS - %i[code_meanings concepts_to_codes concepts_to_codes_in_class known_codes table_is_missing?]).each do |name|
189
+ (CACHED_LOOKUPS - %i[code_meanings concept_ids concepts_to_codes concepts_to_codes_in_class known_codes
190
+ table_is_missing?]).each do |name|
191
191
  define_method(name) do |*args|
192
192
  lookup(name, args) { @strategy.public_send(name, *args) }
193
193
  end
194
194
  end
195
195
 
196
+ # The lookups below take codes. Each one raises CodeCaseMismatchError for
197
+ # a code that matches no stored code exactly but matches one in another
198
+ # case (bead conceptql-rz8s). A vocabulary operator passes
199
+ # check_case: false, because a statement warns about such a code instead.
200
+
201
+ def concept_ids(data_db, vocabulary_id, codes = [], check_case: true)
202
+ ids = lookup(:concept_ids, [ data_db, vocabulary_id, codes ]) do
203
+ @strategy.concept_ids(data_db, vocabulary_id, codes)
204
+ end
205
+ check_case!(data_db, vocabulary_id, codes) if check_case
206
+ ids
207
+ end
208
+
209
+ # The datasets are not cached, but the check runs when they are built.
210
+ def concepts(data_db, vocabulary_id, codes = [], check_case: true)
211
+ ds = @strategy.concepts(data_db, vocabulary_id, codes)
212
+ check_case!(data_db, vocabulary_id, codes) if check_case
213
+ ds
214
+ end
215
+
216
+ def concepts_ds(data_db, vocabulary_id, codes, check_case: true)
217
+ ds = @strategy.concepts_ds(data_db, vocabulary_id, codes)
218
+ check_case!(data_db, vocabulary_id, codes) if check_case
219
+ ds
220
+ end
221
+
196
222
  # The strategy's known_codes first asks whether the vocabulary is empty.
197
223
  # It asks through the block, so that question also reaches the cache.
198
- def known_codes(data_db, vocabulary_id, codes)
199
- lookup(:known_codes, [ data_db, vocabulary_id, codes ]) do
224
+ def known_codes(data_db, vocabulary_id, codes, check_case: true)
225
+ known = lookup(:known_codes, [ data_db, vocabulary_id, codes ]) do
200
226
  @strategy.known_codes(data_db, vocabulary_id, codes) { vocabulary_is_empty?(data_db, vocabulary_id) }
201
227
  end
228
+ check_case!(data_db, vocabulary_id, Array(codes) - known) if check_case
229
+ known
202
230
  end
203
231
 
204
232
  # The strategy's concepts_to_codes first asks whether the concept table is
205
233
  # missing. It asks through the block, so that question also reaches the cache.
206
- def concepts_to_codes(data_db, vocabulary_id, codes = [])
207
- lookup(:concepts_to_codes, [ data_db, vocabulary_id, codes ]) do
234
+ def concepts_to_codes(data_db, vocabulary_id, codes = [], check_case: true)
235
+ found = lookup(:concepts_to_codes, [ data_db, vocabulary_id, codes ]) do
208
236
  @strategy.concepts_to_codes(data_db, vocabulary_id, codes) { table_is_missing?(data_db) }
209
237
  end
238
+ check_case!(data_db, vocabulary_id, Array(codes) - found.map(&:first)) if check_case
239
+ found
210
240
  end
211
241
 
212
242
  # Like concepts_to_codes, it asks about the concept table through the block.
213
- def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes)
214
- lookup(:concepts_to_codes_in_class, [ data_db, vocabulary_id, concept_class_id, codes ]) do
243
+ # A code of the vocabulary that is in another class is not in the answer,
244
+ # so the check finds it in its own case and does not raise.
245
+ def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes, check_case: true)
246
+ found = lookup(:concepts_to_codes_in_class, [ data_db, vocabulary_id, concept_class_id, codes ]) do
215
247
  @strategy.concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes) { table_is_missing?(data_db) }
216
248
  end
249
+ check_case!(data_db, vocabulary_id, Array(codes) - Array(found).map(&:first)) if check_case
250
+ found
217
251
  end
218
252
 
219
253
  # Like concepts_to_codes, it asks about the concept table through the block.
220
- def code_meanings(data_db, vocabulary_id, codes)
221
- lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
254
+ def code_meanings(data_db, vocabulary_id, codes, check_case: true)
255
+ meanings = lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
222
256
  @strategy.code_meanings(data_db, vocabulary_id, codes) { table_is_missing?(data_db) }
223
257
  end
258
+ check_case!(data_db, vocabulary_id, codes) if check_case
259
+ meanings
224
260
  end
225
261
 
226
262
  # Only "the table is there" is kept. A missing table (or a database error,
@@ -232,6 +268,22 @@ module ConceptQL
232
268
  end
233
269
  end
234
270
 
271
+ # Raises CodeCaseMismatchError when a code matches no stored code of the
272
+ # vocabularies exactly, but matches one in another case. The caller passes
273
+ # only the codes that its lookup did not find, when it can tell. A strategy
274
+ # without case_variants (a test double) cannot check.
275
+ def check_case!(data_db, vocabulary_id, codes)
276
+ codes = Array(codes)
277
+ return if codes.empty? || !@strategy.respond_to?(:case_variants)
278
+
279
+ rows = lookup(:case_variants, [ data_db, vocabulary_id, codes ]) do
280
+ @strategy.case_variants(data_db, vocabulary_id, codes)
281
+ end
282
+ mismatches = CodeCaseMismatchError.mismatches(codes, rows)
283
+ raise CodeCaseMismatchError, mismatches unless mismatches.empty?
284
+ end
285
+ private :check_case!
286
+
235
287
  # Runs the lookup, or with caching on returns the result kept for the same
236
288
  # method and arguments. A result that keep rejects is returned, not kept.
237
289
  def lookup(name, args, keep: nil)
@@ -42,10 +42,12 @@ module ConceptQL
42
42
  end
43
43
  end
44
44
 
45
+ # The metadata of each operator that is not hidden, sorted by name
45
46
  def to_metadata(opts = {})
46
- Hash[operators.map do |k, v|
47
- [ k, v.to_metadata(k, opts) ]
48
- end.reject { |_k, v| v[:categories].empty? }.sort_by { |_k, v| v[:name] }]
47
+ operators.reject { |_name, klass| klass.hidden? }
48
+ .map { |name, klass| [ name, klass.to_metadata(name, opts) ] }
49
+ .sort_by { |_name, metadata| metadata[:name] }
50
+ .to_h
49
51
  end
50
52
 
51
53
  # The operators this nodifier can create: its Database's registry for
@@ -7,9 +7,23 @@ module ConceptQL
7
7
  class After < TemporalOperator
8
8
  register __FILE__
9
9
 
10
- desc "For each person, passes along left hand records with a start_date that occurs after the earliest end_date of a right hand record."
10
+ desc "For each patient, keeps the left-hand records whose start_date is after the earliest end_date of the patient's right-hand records. With the within option, each left-hand record is compared with every right-hand record of the patient instead."
11
+ returns "The left-hand records that start after a right-hand record of the same patient ends, each record once. The right-hand records are not returned."
11
12
 
12
13
  allows_at_least_option
14
+ option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. The left-hand record must also start no more than this amount after the end_date of the right-hand record. With this option, each left-hand record is compared with every right-hand record of the patient, not only with the one that ends first. Use a positive amount."
15
+ option_meaning :at_least, "A date adjustment, such as 7d or 1w. The left-hand record must start more than this amount after the end_date of the right-hand record. Use a positive amount."
16
+
17
+ example "All MIs that occurred after a patient's first case of diabetes (250.01)",
18
+ [ "after", { left: [ "icd9", "412" ], right: [ "icd9", "250.01" ] } ]
19
+ example "Prescriptions of interest within three days after an office visit",
20
+ [ "after", { left: [ "ndc", "61392070054", "65084025214", "65726040125", { label: "Prescriptions of Interest" } ],
21
+ right: [ "cpt", "99214", { label: "Office Visit" } ], within: "3d" } ]
22
+ example "Heart attack diagnoses more than 1 week after a diagnosis of diabetes",
23
+ [ "after", { left: [ "icd9", "410.00", "410.01", "410.10", "410.11", { label: "Heart Attack Dx" } ],
24
+ right: [ "icd9", "250.01", { label: "Diabetes Dx" } ], at_least: "1w" } ]
25
+ example "All MIs that occurred after a male patient's 50th birthday",
26
+ [ "after", { left: [ "icd9", "412" ], right: [ "time_window", [ "gender", "Male" ], { start: "50y", end: "50y" } ] } ]
13
27
  within_skip :after
14
28
 
15
29
  def right_stream_query(db)
@@ -7,7 +7,12 @@ module ConceptQL
7
7
  class AnyOverlap < TemporalOperator
8
8
  register __FILE__
9
9
 
10
- desc "For each person, passes along left hand records with a date range that overlaps in any way with a right hand record's date_range."
10
+ desc "For each patient, keeps the left-hand records whose date range shares at least one day with the date range of a right-hand record."
11
+ returns "The left-hand records that overlap the date range of at least one right-hand record of the same patient, each record once. The right-hand records are not returned."
12
+ option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. Before the comparison, the date range of each right-hand record is widened by this amount in each direction: its start_date moves back and its end_date moves forward. Use a positive amount."
13
+
14
+ example "All MIs that overlap with the month of 2010-06",
15
+ [ "any_overlap", { left: [ "icd9", "412" ], right: [ "date_range", { start: "2010-06-01", end: "2010-06-30" } ] } ]
11
16
 
12
17
  def where_clause
13
18
  ((within_start <= l_start_date) & (l_start_date <= within_end)) | ((l_start_date <= within_start) & (within_start <= l_end_date))
@@ -7,9 +7,19 @@ module ConceptQL
7
7
  class Before < TemporalOperator
8
8
  register __FILE__
9
9
 
10
- desc "For each person, passes along left hand records with an end_date that occurs before the most recent start_date of a right hand record."
10
+ desc "For each patient, keeps the left-hand records whose end_date is before the latest start_date of the patient's right-hand records. With the within option, each left-hand record is compared with every right-hand record of the patient instead."
11
+ returns "The left-hand records that end before a right-hand record of the same patient starts, each record once. The right-hand records are not returned."
11
12
 
12
13
  allows_at_least_option
14
+ option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. The left-hand record must also end no more than this amount before the start_date of the right-hand record. With this option, each left-hand record is compared with every right-hand record of the patient, not only with the one that starts last. Use a positive amount."
15
+ option_meaning :at_least, "A date adjustment, such as 7d or 1w. The left-hand record must end more than this amount before the start_date of the right-hand record. Use a positive amount."
16
+
17
+ example "All MIs that occurred before a patient's last case of diabetes (250.01)",
18
+ [ "before", { left: [ "icd9", "412" ], right: [ "icd9", "250.01" ] } ]
19
+ example "All MIs that occurred before a patient's first case of diabetes (250.01)",
20
+ [ "before", { left: [ "icd9", "412" ], right: [ "first", [ "icd9", "250.01" ] ] } ]
21
+ example "Heart attacks that end no more than 1 year before death",
22
+ [ "before", { left: [ "icd9", "410.00", "410.01", "410.10", "410.11" ], right: [ "death" ], within: "1y" } ]
13
23
  within_skip :before
14
24
 
15
25
  def right_stream_query(db)
@@ -6,8 +6,13 @@ module ConceptQL
6
6
  module Operators
7
7
  # Base class for all operators that take two streams, a left-hand and a right-hand
8
8
  class BinaryOperatorOperator < Operator
9
- option :left, type: :upstream
10
- option :right, type: :upstream
9
+ option :left, type: :upstream,
10
+ meaning: "The left-hand stream. The operator compares these records with the right-hand stream and passes on the ones that meet its condition."
11
+ option :right, type: :upstream,
12
+ meaning: "The right-hand stream. The operator compares the left-hand records with these records and does not pass them on."
13
+ # The operands are the left and right options. A positional upstream
14
+ # would be ignored, so it is an error (Ryan ruled 2026-11-03).
15
+ validate_no_upstreams
11
16
  validate_no_arguments
12
17
  validate_option Array, :left, :right
13
18
  validate_required_options :left, :right
@@ -68,6 +73,11 @@ module ConceptQL
68
73
  @right = to_op(options[:right]) if options[:right].is_a?(Array)
69
74
  end
70
75
 
76
+ # create_upstreams leaves the positional upstreams as statements
77
+ def upstream_operator_names
78
+ @upstreams.map { |stmt| stmt.is_a?(Operator) ? stmt.op_name : stmt.first }
79
+ end
80
+
71
81
  def left_stream(db)
72
82
  left_stream_query(db)
73
83
  end
@@ -17,10 +17,15 @@ module ConceptQL
17
17
  vocabularies.length > 1
18
18
  end
19
19
 
20
- desc "Passes along the events of its upstream streams that were reported in the same source record " \
21
- "(the same context). A record is kept only if every upstream stream " \
22
- "has at least one event in it. If any one upstream has no event in a record, the events of all " \
23
- "streams in that record are dropped, without a warning."
20
+ desc "Passes along the records of its upstreams that were reported in the same context as a record of every other upstream. " \
21
+ "In the GDM, a context is the claim or other source record that reported the record."
22
+
23
+ returns "The records of each upstream whose context also has a record of every other upstream. " \
24
+ "The operator does not compare dates. If one upstream has no record in a context, the records of all upstreams " \
25
+ "in that context are not passed along. Person and death records have no context, so they never pass."
26
+
27
+ example "Diabetes diagnoses reported as a reason for an office visit, and those office visits",
28
+ [ "co_reported", [ "icd9", "250.00" ], [ "cpt", "99214" ] ]
24
29
 
25
30
  allows_many_upstreams
26
31
  category "Combine Streams"
@@ -7,7 +7,12 @@ module ConceptQL
7
7
  class Contains < TemporalOperator
8
8
  register __FILE__
9
9
 
10
- desc "For each person, passes along left hand records with a start_date and end_date containing a right hand record's start_date and end_date."
10
+ desc "For each patient, keeps the left-hand records whose date range encloses the date range of a right-hand record. The left-hand record starts on or before the start_date of the right-hand record and ends on or after its end_date."
11
+ returns "The left-hand records that enclose the date range of at least one right-hand record of the same patient, each record once. The right-hand records are not returned."
12
+ option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. Before the comparison, the date range of each left-hand record is widened by this amount in each direction: its start_date moves back and its end_date moves forward. The returned records keep their own dates. Use a positive amount."
13
+
14
+ example "All MIs occurring on 2010-06-22",
15
+ [ "contains", { left: [ "icd9", "412" ], right: [ "date_range", { start: "2010-06-22", end: "2010-06-22" } ] } ]
11
16
 
12
17
  def where_clause
13
18
  (within_start <= r_start_date) & (r_end_date <= within_end)