openehr 2.1.0 → 2.3.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/README.rdoc +19 -6
- data/lib/openehr/aql/engine/contains_resolver.rb +98 -7
- data/lib/openehr/aql/engine/dataset.rb +3 -1
- data/lib/openehr/aql/engine/predicate_evaluator.rb +70 -3
- data/lib/openehr/aql/engine.rb +45 -12
- data/lib/openehr/parser/opt_parser.rb +12 -425
- data/lib/openehr/parser/xml_archetype_parser.rb +255 -0
- data/lib/openehr/parser/xml_constraint_parsing.rb +264 -0
- data/lib/openehr/parser/xml_domain_type_parsing.rb +128 -0
- data/lib/openehr/parser/xml_primitive_parsing.rb +145 -0
- data/lib/openehr/parser.rb +1 -0
- data/lib/openehr/rm/common/generic.rb +4 -7
- data/lib/openehr/rm/composition/content/entry.rb +4 -1
- data/lib/openehr/rm/data_structures/item_structure/representation.rb +12 -2
- data/lib/openehr/rm/data_types/basic.rb +8 -9
- data/lib/openehr/rm/data_types/encapsulated.rb +16 -0
- data/lib/openehr/rm/data_types/quantity/date_time.rb +168 -0
- data/lib/openehr/rm/data_types/quantity.rb +64 -9
- data/lib/openehr/rm/ehr.rb +6 -6
- data/lib/openehr/rm/factory.rb +4 -0
- data/lib/openehr/serializer/adl_serializer.rb +151 -14
- data/lib/openehr/serializer/xml_serializer.rb +345 -99
- data/lib/openehr/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10d6fc971443c1c8014d60bebd609c71757870ba0bb0d710cc35f014981b7ca1
|
|
4
|
+
data.tar.gz: 93b4ce3dd288a4943c0b4112d6ec9cb55ee1438d0411f66a7c47d5d26ea8b4b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cdb1ec0e04bda30a3d15fed92ef940f0a4aacde6f4e6570f8f5c5f6b0c6e14320166f88834c0a657a1b37d6973edc63650ce641bc1f43d92043289a90b95f5ed
|
|
7
|
+
data.tar.gz: 4b42fece35848e073eb3111d5f7abd8b85d02986799433df8330ff65bbbf5c1b8d979cf2d16bee175d4faf4d908c7cf78479a8dd9adfa3c2c6035c84a51d59a5
|
data/README.rdoc
CHANGED
|
@@ -7,9 +7,9 @@ A Ruby implementation of the openEHR specifications
|
|
|
7
7
|
|
|
8
8
|
= Requirements
|
|
9
9
|
|
|
10
|
-
* Supports the Ruby versions currently maintained upstream by Ruby core: 3.
|
|
11
|
-
* Developed with CRuby 4.0.6 on Linux; CI covers 3.
|
|
12
|
-
* Other versions, including 3.1 and 3.
|
|
10
|
+
* Supports the Ruby versions currently maintained upstream by Ruby core: 3.3, 3.4, and 4.0.
|
|
11
|
+
* Developed with CRuby 4.0.6 on Linux; CI covers 3.3, 3.4, and 4.0.
|
|
12
|
+
* Other versions, including 3.1 and 3.2, are not supported (both reached EOL).
|
|
13
13
|
|
|
14
14
|
=Description
|
|
15
15
|
|
|
@@ -25,6 +25,9 @@ The intention is to have a sample EHR to deploy quickly with Ruby on Rails for d
|
|
|
25
25
|
|
|
26
26
|
This package includes:
|
|
27
27
|
* ADL 1.4 parser
|
|
28
|
+
* An XML archetype parser (reads the canonical openEHR ITS-XML shape
|
|
29
|
+
that this gem's own XMLSerializer produces, sharing its constraint-
|
|
30
|
+
tree node builders with the OPT parser)
|
|
28
31
|
* AM/RM packages based on the openEHR specification 1.0.2, plus RM 1.1.0 deltas
|
|
29
32
|
* ADL/XML/JSON serializers and an ADL archetype validator
|
|
30
33
|
* AQL (Archetype Query Language): a parser plus an in-memory execution
|
|
@@ -32,7 +35,6 @@ This package includes:
|
|
|
32
35
|
already have in memory (see "Querying with AQL" below)
|
|
33
36
|
|
|
34
37
|
This package excludes:
|
|
35
|
-
* An XML archetype parser (in progress)
|
|
36
38
|
* Persistent data stores and search indexes - AQL here only ever
|
|
37
39
|
queries in-memory RM object graphs; the moment your data lives in a
|
|
38
40
|
database, that integration (e.g. openehr-rails) owns fetching it out
|
|
@@ -69,13 +71,24 @@ way, from RM 1.1.0 onward.
|
|
|
69
71
|
FEEDER_AUDIT_DETAILS.other_details 1.1.0 Optional ITEM_STRUCTURE meta-data (SPECRM-74)
|
|
70
72
|
ACTIVITY.timing 1.1.0 Now optional (was mandatory pre-1.1.0)
|
|
71
73
|
ISM_TRANSITION.reason 1.1.0 Optional List<DV_TEXT>
|
|
74
|
+
ISM_TRANSITION.transition 1.1.0 Now optional (was mandatory pre-1.1.0)
|
|
75
|
+
DV_IDENTIFIER.issuer/assigner/type 1.0.4 Optional (only id remains 1..1 mandatory)
|
|
76
|
+
PARTICIPATION.mode 1.0.4 Now optional (was mandatory pre-1.0.4)
|
|
77
|
+
EHR.contributions / EHR.compositions 1.1.0 Now optional (was mandatory pre-1.1.0)
|
|
78
|
+
DV_MULTIMEDIA.size 1.1.0 Optional Integer; falls back to the inherited
|
|
79
|
+
value-based size when not given explicitly
|
|
80
|
+
ELEMENT.null_reason 1.1.0 Optional DV_TEXT; requires null_flavor to be set
|
|
72
81
|
COMPOSITION.category = "episodic" 1.1.0 A terminology value, not a code change (SPECRM-89);
|
|
73
82
|
already supported via the pluggable
|
|
74
83
|
OpenEHR::TerminologyService seam
|
|
75
84
|
|
|
85
|
+
DV_DATE/DV_TIME/DV_DATE_TIME now support #add/#subtract against a
|
|
86
|
+
DV_DURATION (calendar-aware: month-end clamping, leap years, and
|
|
87
|
+
day/timezone wraparound), and DV_DURATION supports +/-/multiply
|
|
88
|
+
against another DV_DURATION.
|
|
89
|
+
|
|
76
90
|
Not yet incorporated from RM 1.1.0 / BASE 1.2.0: ITEM_TAG (still in a
|
|
77
|
-
development branch, not yet in a released RM version)
|
|
78
|
-
points of DV_DURATION arithmetic beyond negation/unary minus.
|
|
91
|
+
development branch, not yet in a released RM version).
|
|
79
92
|
|
|
80
93
|
=Querying with AQL
|
|
81
94
|
|
|
@@ -11,15 +11,18 @@ module OpenEHR
|
|
|
11
11
|
# CONTAINS chain searching a matched Locatable's *entire* subtree
|
|
12
12
|
# (any depth, via Pathable#path_children) for the next class,
|
|
13
13
|
# archetype-predicate filtering, AND/OR-grouped sibling branches
|
|
14
|
-
# (cross-product / union of each branch's matches)
|
|
15
|
-
# (parent matches survive only when the negated class is absent)
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# (cross-product / union of each branch's matches), NOT CONTAINS
|
|
15
|
+
# (parent matches survive only when the negated class is absent),
|
|
16
|
+
# and an EHR-level standardPredicate (e.g. "[ehr_id/value=$ehr_id]",
|
|
17
|
+
# reusing PathEvaluator/PredicateEvaluator's existing comparison
|
|
18
|
+
# machinery rather than new bespoke logic). standardPredicate/
|
|
19
|
+
# nodePredicate filtering on non-EHR CONTAINS classes is added by a
|
|
20
|
+
# later engine milestone (see predicate_matches? below).
|
|
19
21
|
class ContainsResolver
|
|
20
|
-
def initialize(from_clause, dataset)
|
|
22
|
+
def initialize(from_clause, dataset, params: {})
|
|
21
23
|
@root = from_clause.containment
|
|
22
24
|
@dataset = dataset
|
|
25
|
+
@params = params
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def each_binding
|
|
@@ -56,7 +59,11 @@ module OpenEHR
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def resolve_class_expression(class_expression, ehr_record, pool)
|
|
59
|
-
|
|
62
|
+
if ehr_root?(class_expression)
|
|
63
|
+
return [] unless ehr_predicate_matches?(ehr_record, class_expression.predicate)
|
|
64
|
+
|
|
65
|
+
return [[variables_for(class_expression, ehr_record), ehr_record]]
|
|
66
|
+
end
|
|
60
67
|
|
|
61
68
|
pool.select { |candidate| matches?(candidate, class_expression) }
|
|
62
69
|
.map { |candidate| [variables_for(class_expression, candidate), candidate] }
|
|
@@ -142,11 +149,95 @@ module OpenEHR
|
|
|
142
149
|
true
|
|
143
150
|
when Model::ArchetypePredicate
|
|
144
151
|
candidate.respond_to?(:archetype_node_id) && candidate.archetype_node_id == predicate.archetype_id
|
|
152
|
+
when Model::NodePredicate
|
|
153
|
+
node_predicate_matches?(candidate, predicate)
|
|
154
|
+
when Model::StandardPredicate
|
|
155
|
+
standard_predicate_matches?(candidate, predicate)
|
|
156
|
+
when Model::PredicateAnd
|
|
157
|
+
predicate_matches?(candidate, predicate.left) && predicate_matches?(candidate, predicate.right)
|
|
158
|
+
when Model::PredicateOr
|
|
159
|
+
predicate_matches?(candidate, predicate.left) || predicate_matches?(candidate, predicate.right)
|
|
145
160
|
else
|
|
146
161
|
raise ExecutionError, "cannot evaluate a #{predicate.class} predicate yet"
|
|
147
162
|
end
|
|
148
163
|
end
|
|
149
164
|
|
|
165
|
+
# nodePredicate "[atNNNN]" matches by archetype_node_id alone;
|
|
166
|
+
# "[atNNNN, 'Name']" additionally requires the node's own `name`
|
|
167
|
+
# DV_TEXT to equal the given display name.
|
|
168
|
+
def node_predicate_matches?(candidate, predicate)
|
|
169
|
+
return false unless candidate.respond_to?(:archetype_node_id) && candidate.archetype_node_id == predicate.code
|
|
170
|
+
return true unless predicate.value
|
|
171
|
+
|
|
172
|
+
unless predicate.value.is_a?(String)
|
|
173
|
+
raise ExecutionError, "cannot evaluate a #{predicate.value.class} node predicate value yet"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
node_name_matches?(candidate, predicate.value)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def node_name_matches?(candidate, value)
|
|
180
|
+
return false unless candidate.respond_to?(:name)
|
|
181
|
+
|
|
182
|
+
name = candidate.name
|
|
183
|
+
name.respond_to?(:value) && name.value == value
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# standardPredicate on a CONTAINS class expression reuses the same
|
|
187
|
+
# operand-resolution/comparison machinery the E10 EHR-root
|
|
188
|
+
# predicate already established, just rooted at the matched
|
|
189
|
+
# candidate instead of the Dataset::EHRRecord.
|
|
190
|
+
def standard_predicate_matches?(candidate, predicate)
|
|
191
|
+
left = predicate_operand(candidate, predicate.path)
|
|
192
|
+
right = predicate_operand(candidate, predicate.operand)
|
|
193
|
+
return false if left.nil? || right.nil?
|
|
194
|
+
|
|
195
|
+
PredicateEvaluator.compare(left, predicate.operator, right)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# An EHR-root predicate (e.g. "[ehr_id/value=$ehr_id]") is a
|
|
199
|
+
# standardPredicate/PredicateAnd/PredicateOr tree evaluated against
|
|
200
|
+
# the Dataset::EHRRecord itself, reusing PathEvaluator.navigate
|
|
201
|
+
# (which already special-cases EHRRecord) and
|
|
202
|
+
# PredicateEvaluator.compare/lookup_param rather than new
|
|
203
|
+
# comparison code.
|
|
204
|
+
def ehr_predicate_matches?(ehr_record, predicate)
|
|
205
|
+
case predicate
|
|
206
|
+
when nil
|
|
207
|
+
true
|
|
208
|
+
when Model::StandardPredicate
|
|
209
|
+
standard_ehr_predicate_matches?(ehr_record, predicate)
|
|
210
|
+
when Model::PredicateAnd
|
|
211
|
+
ehr_predicate_matches?(ehr_record, predicate.left) && ehr_predicate_matches?(ehr_record, predicate.right)
|
|
212
|
+
when Model::PredicateOr
|
|
213
|
+
ehr_predicate_matches?(ehr_record, predicate.left) || ehr_predicate_matches?(ehr_record, predicate.right)
|
|
214
|
+
else
|
|
215
|
+
raise ExecutionError, "cannot evaluate a #{predicate.class} EHR predicate yet"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def standard_ehr_predicate_matches?(ehr_record, predicate)
|
|
220
|
+
left = predicate_operand(ehr_record, predicate.path)
|
|
221
|
+
right = predicate_operand(ehr_record, predicate.operand)
|
|
222
|
+
return false if left.nil? || right.nil?
|
|
223
|
+
|
|
224
|
+
PredicateEvaluator.compare(left, predicate.operator, right)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Shared by both the EHR-root and the CONTAINS-class-expression
|
|
228
|
+
# standardPredicate paths - `root` is whichever object the
|
|
229
|
+
# predicate's path is rooted at (a Dataset::EHRRecord or a
|
|
230
|
+
# CONTAINS-matched Pathable candidate); PathEvaluator.navigate
|
|
231
|
+
# already special-cases EHRRecord and falls through to ordinary
|
|
232
|
+
# Pathable navigation for everything else.
|
|
233
|
+
def predicate_operand(root, operand)
|
|
234
|
+
return PredicateEvaluator.lookup_param(operand, @params) if operand.is_a?(Model::Parameter)
|
|
235
|
+
return operand.value if operand.is_a?(Model::Literal)
|
|
236
|
+
return operand unless operand.is_a?(Model::ObjectPath)
|
|
237
|
+
|
|
238
|
+
operand.segments.reduce(root) { |current, segment| current.nil? ? nil : PathEvaluator.navigate(current, segment) }
|
|
239
|
+
end
|
|
240
|
+
|
|
150
241
|
def variables_for(class_expression, matched)
|
|
151
242
|
return {} unless class_expression.variable
|
|
152
243
|
|
|
@@ -95,7 +95,9 @@ module OpenEHR
|
|
|
95
95
|
ehr_id: ehr.ehr_id,
|
|
96
96
|
ehr: ehr,
|
|
97
97
|
ehr_status: ehr.ehr_status&.latest_version&.data,
|
|
98
|
-
|
|
98
|
+
# EHR.compositions is optional (0..1) since RM 1.1.0 - Array()
|
|
99
|
+
# normalizes a nil (no compositions at all) to an empty list.
|
|
100
|
+
compositions: Array(ehr.compositions).map { |versioned_composition| versioned_composition.latest_version.data }
|
|
99
101
|
)
|
|
100
102
|
end
|
|
101
103
|
|
|
@@ -2,14 +2,24 @@ module OpenEHR
|
|
|
2
2
|
module AQL
|
|
3
3
|
# Evaluates a WHERE clause's boolean expression tree against a
|
|
4
4
|
# Binding and the query's runtime params. E5 scope: comparisons
|
|
5
|
-
# (reusing PathEvaluator for both sides), AND/OR/NOT and EXISTS.
|
|
6
|
-
# LIKE,
|
|
7
|
-
#
|
|
5
|
+
# (reusing PathEvaluator for both sides), AND/OR/NOT and EXISTS. E12
|
|
6
|
+
# adds LIKE (glob match, not SQL's %/_); E13 adds MATCHES against a
|
|
7
|
+
# literal value list (a URI/TERMINOLOGY(...) operand names an
|
|
8
|
+
# external value-set lookup this engine has no terminology service
|
|
9
|
+
# wired for, so those raise a clear ExecutionError rather than
|
|
10
|
+
# silently matching everything). Generic functionCall operands are
|
|
11
|
+
# added by a later engine milestone.
|
|
8
12
|
module PredicateEvaluator
|
|
9
13
|
COMPARATORS = {
|
|
10
14
|
'=' => :==, '!=' => :!=, '<' => :<, '<=' => :<=, '>' => :>, '>=' => :>=
|
|
11
15
|
}.freeze
|
|
12
16
|
|
|
17
|
+
# LIKE's glob syntax (AQL spec, not SQL's %/_): '?' matches exactly
|
|
18
|
+
# one character, '*' matches zero or more, anything else is a
|
|
19
|
+
# literal character - and the whole value must match, not a
|
|
20
|
+
# substring.
|
|
21
|
+
GLOB_TO_REGEXP = { '*' => '.*', '?' => '.' }.freeze
|
|
22
|
+
|
|
13
23
|
module_function
|
|
14
24
|
|
|
15
25
|
# `expression` is nil when there is no WHERE clause at all.
|
|
@@ -27,11 +37,68 @@ module OpenEHR
|
|
|
27
37
|
!matches?(expression.operand, binding, params)
|
|
28
38
|
when Model::ExistsExpr
|
|
29
39
|
!PathEvaluator.evaluate(expression.path, binding).nil?
|
|
40
|
+
when Model::LikeExpr
|
|
41
|
+
evaluate_like(expression, binding, params)
|
|
42
|
+
when Model::MatchesExpr
|
|
43
|
+
evaluate_matches(expression, binding, params)
|
|
30
44
|
else
|
|
31
45
|
raise ExecutionError, "cannot evaluate a #{expression.class} WHERE expression yet"
|
|
32
46
|
end
|
|
33
47
|
end
|
|
34
48
|
|
|
49
|
+
def evaluate_like(like_expr, binding, params)
|
|
50
|
+
value = PathEvaluator.evaluate(like_expr.path, binding)
|
|
51
|
+
pattern = resolve_operand(like_expr.operand, binding, params)
|
|
52
|
+
return false if value.nil? || pattern.nil?
|
|
53
|
+
|
|
54
|
+
like_regexp(pattern).match?(value.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def like_regexp(pattern)
|
|
58
|
+
body = pattern.chars.map { |char| GLOB_TO_REGEXP[char] || Regexp.escape(char) }.join
|
|
59
|
+
Regexp.new("\\A#{body}\\z", Regexp::MULTILINE)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def evaluate_matches(matches_expr, binding, params)
|
|
63
|
+
value = PathEvaluator.evaluate(matches_expr.path, binding)
|
|
64
|
+
return false if value.nil?
|
|
65
|
+
|
|
66
|
+
case matches_expr.operand
|
|
67
|
+
when Model::MatchesValueList
|
|
68
|
+
matches_value_list?(value, matches_expr.operand, params)
|
|
69
|
+
when Model::UriRef, Model::TerminologyFunctionCall
|
|
70
|
+
raise ExecutionError,
|
|
71
|
+
"MATCHES against a #{matches_expr.operand.class} names an external terminology service lookup " \
|
|
72
|
+
'(a value-set expansion), which this engine has none wired in for - ' \
|
|
73
|
+
'OpenEHR::TerminologyService only validates a single known code, it cannot expand a value set'
|
|
74
|
+
else
|
|
75
|
+
raise ExecutionError, "cannot evaluate a #{matches_expr.operand.class} MATCHES operand yet"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def matches_value_list?(value, value_list, params)
|
|
80
|
+
value_list.items.any? do |item|
|
|
81
|
+
candidate = matches_list_item_value(item, params)
|
|
82
|
+
!candidate.nil? && value == candidate
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def matches_list_item_value(item, params)
|
|
87
|
+
case item
|
|
88
|
+
when Model::Parameter
|
|
89
|
+
lookup_param(item, params)
|
|
90
|
+
when Model::Literal
|
|
91
|
+
item.value
|
|
92
|
+
when Model::TerminologyFunctionCall
|
|
93
|
+
raise ExecutionError,
|
|
94
|
+
"MATCHES against a #{item.class} names an external terminology service lookup " \
|
|
95
|
+
'(a value-set expansion), which this engine has none wired in for - ' \
|
|
96
|
+
'OpenEHR::TerminologyService only validates a single known code, it cannot expand a value set'
|
|
97
|
+
else
|
|
98
|
+
raise ExecutionError, "cannot evaluate a #{item.class} MATCHES value-list item yet"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
35
102
|
# A comparison against an absent (nil) value is neither true nor
|
|
36
103
|
# false in AQL/SQL terms - it simply fails to select the row, the
|
|
37
104
|
# same as SQL's NULL-comparison-is-UNKNOWN convention.
|
data/lib/openehr/aql/engine.rb
CHANGED
|
@@ -7,23 +7,27 @@ require_relative 'result_set'
|
|
|
7
7
|
module OpenEHR
|
|
8
8
|
module AQL
|
|
9
9
|
# Orchestrates one Query execution against a Dataset, in the order
|
|
10
|
-
# CONTAINS -> WHERE -> ORDER BY -> SELECT -> DISTINCT -> LIMIT/OFFSET.
|
|
10
|
+
# CONTAINS -> WHERE -> ORDER BY -> SELECT -> DISTINCT -> TOP/LIMIT/OFFSET.
|
|
11
11
|
#
|
|
12
12
|
# A SELECT clause made entirely of aggregate columns
|
|
13
13
|
# (Model::AggregateFunctionCall) collapses the whole (post-WHERE)
|
|
14
14
|
# binding set into a single summary row instead - ORDER BY/DISTINCT/
|
|
15
|
-
# LIMIT don't apply to it, matching plain SQL aggregate-without-
|
|
16
|
-
# GROUP-BY semantics.
|
|
17
|
-
#
|
|
15
|
+
# TOP/LIMIT don't apply to it, matching plain SQL aggregate-without-
|
|
16
|
+
# GROUP-BY semantics. A SELECT mixing aggregate and non-aggregate
|
|
17
|
+
# columns implicitly groups by every non-aggregate column's value
|
|
18
|
+
# (the standard SQL reading when no explicit GROUP BY is given) -
|
|
19
|
+
# zero surviving bindings therefore means zero groups/rows, unlike
|
|
20
|
+
# the all-aggregate case's single row of aggregate defaults. Generic
|
|
21
|
+
# (non-aggregate) function calls are not yet supported.
|
|
18
22
|
class Engine
|
|
19
23
|
def initialize(query)
|
|
20
24
|
@query = query
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def execute(dataset, params: {})
|
|
24
|
-
bindings = ContainsResolver.new(@query.from_clause, Dataset.wrap(dataset)).each_binding
|
|
28
|
+
bindings = ContainsResolver.new(@query.from_clause, Dataset.wrap(dataset), params: params).each_binding
|
|
25
29
|
.select { |binding| where_matches?(binding, params) }
|
|
26
|
-
rows = aggregate_query? ?
|
|
30
|
+
rows = aggregate_query? ? aggregate_rows(bindings) : select_rows(bindings)
|
|
27
31
|
ResultSet.new(columns: @query.select_clause.columns.map { |column| column_name(column) }, rows: rows)
|
|
28
32
|
end
|
|
29
33
|
|
|
@@ -36,7 +40,7 @@ module OpenEHR
|
|
|
36
40
|
def select_rows(bindings)
|
|
37
41
|
rows = project(order(bindings))
|
|
38
42
|
rows = rows.uniq if @query.select_clause.distinct
|
|
39
|
-
apply_limit(rows)
|
|
43
|
+
apply_limit(apply_top(rows))
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def order(bindings)
|
|
@@ -77,18 +81,47 @@ module OpenEHR
|
|
|
77
81
|
rows.drop(limit_clause.offset).take(limit_clause.limit)
|
|
78
82
|
end
|
|
79
83
|
|
|
84
|
+
# TOP is deprecated in favour of LIMIT (still grammatical); combined
|
|
85
|
+
# with LIMIT it has no defined meaning, so raise rather than guess.
|
|
86
|
+
def apply_top(rows)
|
|
87
|
+
top = @query.select_clause.top
|
|
88
|
+
return rows unless top
|
|
89
|
+
raise ExecutionError, 'combining SELECT TOP with LIMIT is not supported - use LIMIT alone' if @query.limit_clause
|
|
90
|
+
|
|
91
|
+
top.direction == :backward ? rows.last(top.count) : rows.take(top.count)
|
|
92
|
+
end
|
|
93
|
+
|
|
80
94
|
def aggregate_query?
|
|
81
95
|
@query.select_clause.columns.any? { |column| column.expression.is_a?(Model::AggregateFunctionCall) }
|
|
82
96
|
end
|
|
83
97
|
|
|
98
|
+
def all_aggregate_columns?
|
|
99
|
+
@query.select_clause.columns.all? { |column| column.expression.is_a?(Model::AggregateFunctionCall) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# All-aggregate columns collapse every surviving binding into one
|
|
103
|
+
# summary row, same as plain SQL aggregate-without-GROUP-BY. A mix
|
|
104
|
+
# groups by the non-aggregate columns' values instead - every
|
|
105
|
+
# binding in a group shares those values by construction, so the
|
|
106
|
+
# group's first binding is as good as any for reading them back.
|
|
107
|
+
def aggregate_rows(bindings)
|
|
108
|
+
return [aggregate_row(bindings)] if all_aggregate_columns?
|
|
109
|
+
|
|
110
|
+
bindings.group_by { |binding| group_key(binding) }.map { |_key, group| aggregate_row(group) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def group_key(binding)
|
|
114
|
+
@query.select_clause.columns.map do |column|
|
|
115
|
+
next nil if column.expression.is_a?(Model::AggregateFunctionCall)
|
|
116
|
+
|
|
117
|
+
PathEvaluator.evaluate(column.expression, binding)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
84
121
|
def aggregate_row(bindings)
|
|
85
122
|
@query.select_clause.columns.map do |column|
|
|
86
123
|
call = column.expression
|
|
87
|
-
|
|
88
|
-
raise ExecutionError, 'mixing aggregate and non-aggregate SELECT columns is not yet supported'
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
evaluate_aggregate(call, bindings)
|
|
124
|
+
call.is_a?(Model::AggregateFunctionCall) ? evaluate_aggregate(call, bindings) : PathEvaluator.evaluate(call, bindings.first)
|
|
92
125
|
end
|
|
93
126
|
end
|
|
94
127
|
|