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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e728b70dac1326f832dd44e631442ae3e9ed8b3a3c81a2b5300c23e01a9068e
4
- data.tar.gz: 0003101647c193f7d35d575a3eef1a92a8e34038a849719a0598e34109f92a52
3
+ metadata.gz: 10d6fc971443c1c8014d60bebd609c71757870ba0bb0d710cc35f014981b7ca1
4
+ data.tar.gz: 93b4ce3dd288a4943c0b4112d6ec9cb55ee1438d0411f66a7c47d5d26ea8b4b9
5
5
  SHA512:
6
- metadata.gz: 334d9390f1e59a2a68a812fda1c946ddf3a74504b4234f6363dddee9e42d8763b01d8bbee50fed8c894318c4bec794b4ba743310961e4ba482164ccf9ec660e7
7
- data.tar.gz: 611283bb569b4bc0672a502d09b6c485db8ca47131dd8393fdb6ac67589bbdfeb305d0a98a628997a6091ffd6dda03d57695560a61308104f96c06f7e95a9c98
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.2, 3.4, and 4.0.
11
- * Developed with CRuby 4.0.6 on Linux; CI covers 3.2, 3.4, and 4.0.
12
- * Other versions, including 3.1 and 3.3, are not supported.
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) and the finer
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) and NOT CONTAINS
15
- # (parent matches survive only when the negated class is absent).
16
- # standardPredicate/nodePredicate filtering and an EHR-level
17
- # predicate (e.g. "[ehr_id/value=$ehr_id]") are added by a later
18
- # engine milestone.
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
- return [[variables_for(class_expression, ehr_record), ehr_record]] if ehr_root?(class_expression)
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
- compositions: ehr.compositions.map { |versioned_composition| versioned_composition.latest_version.data }
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, MATCHES and functionCall operands are added by later engine
7
- # milestones.
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.
@@ -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. Mixing aggregate and plain columns, and
17
- # generic (non-aggregate) function calls, are not yet supported.
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? ? [aggregate_row(bindings)] : select_rows(bindings)
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
- unless call.is_a?(Model::AggregateFunctionCall)
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