openehr 1.3.0 → 2.0.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 +93 -12
- data/lib/openehr/am/archetype/constraint_model/primitive.rb +204 -6
- data/lib/openehr/am/archetype/constraint_model.rb +228 -4
- data/lib/openehr/am/archetype/ontology.rb +2 -2
- data/lib/openehr/am/archetype.rb +101 -2
- data/lib/openehr/am/openehr_profile/data_types/basic.rb +28 -0
- data/lib/openehr/am/openehr_profile/data_types/quantity.rb +88 -0
- data/lib/openehr/am/openehr_profile/data_types/text.rb +23 -0
- data/lib/openehr/am/template.rb +25 -3
- data/lib/openehr/aql/engine/binding.rb +13 -0
- data/lib/openehr/aql/engine/contains_resolver.rb +161 -0
- data/lib/openehr/aql/engine/dataset.rb +122 -0
- data/lib/openehr/aql/engine/path_evaluator.rb +137 -0
- data/lib/openehr/aql/engine/predicate_evaluator.rb +65 -0
- data/lib/openehr/aql/engine.rb +119 -0
- data/lib/openehr/aql/errors.rb +27 -0
- data/lib/openehr/aql/lexer.rb +295 -0
- data/lib/openehr/aql/model/containment.rb +45 -0
- data/lib/openehr/aql/model/from_clause.rb +35 -0
- data/lib/openehr/aql/model/function_call.rb +56 -0
- data/lib/openehr/aql/model/identified_path.rb +20 -0
- data/lib/openehr/aql/model/literal.rb +15 -0
- data/lib/openehr/aql/model/object_path.rb +34 -0
- data/lib/openehr/aql/model/order_by_and_limit.rb +50 -0
- data/lib/openehr/aql/model/predicate.rb +84 -0
- data/lib/openehr/aql/model/query.rb +24 -0
- data/lib/openehr/aql/model/select_clause.rb +29 -0
- data/lib/openehr/aql/model/where_clause.rb +105 -0
- data/lib/openehr/aql/model.rb +14 -0
- data/lib/openehr/aql/parser.rb +484 -0
- data/lib/openehr/aql/result_set.rb +54 -0
- data/lib/openehr/aql.rb +28 -0
- data/lib/openehr/assumed_library_types.rb +68 -17
- data/lib/openehr/parser/adl_grammar.tt +29 -13
- data/lib/openehr/parser/adl_parser.rb +18 -4
- data/lib/openehr/parser/archetype_validator.rb +126 -0
- data/lib/openehr/parser/exception.rb +13 -0
- data/lib/openehr/parser/opt_parser.rb +162 -9
- data/lib/openehr/parser.rb +2 -0
- data/lib/openehr/path.rb +195 -0
- data/lib/openehr/rm/common/archetyped.rb +116 -6
- data/lib/openehr/rm/common/change_control.rb +3 -8
- data/lib/openehr/rm/common/directory.rb +4 -0
- data/lib/openehr/rm/common/generic.rb +24 -10
- data/lib/openehr/rm/composition/content/entry.rb +24 -5
- data/lib/openehr/rm/composition/content/navigation.rb +2 -1
- data/lib/openehr/rm/composition.rb +19 -2
- data/lib/openehr/rm/data_structures/history.rb +3 -0
- data/lib/openehr/rm/data_structures/item_structure/representation.rb +13 -9
- data/lib/openehr/rm/data_structures/item_structure.rb +28 -15
- data/lib/openehr/rm/data_types/encapsulated.rb +23 -3
- data/lib/openehr/rm/data_types/quantity/date_time.rb +9 -1
- data/lib/openehr/rm/data_types/quantity.rb +66 -14
- data/lib/openehr/rm/data_types/text.rb +8 -0
- data/lib/openehr/rm/data_types/time_specification.rb +5 -4
- data/lib/openehr/rm/demographic.rb +4 -3
- data/lib/openehr/rm/ehr.rb +20 -1
- data/lib/openehr/rm/factory.rb +234 -6
- data/lib/openehr/rm/integration.rb +1 -0
- data/lib/openehr/rm/support/identification.rb +37 -8
- data/lib/openehr/rm/support/measurement.rb +32 -0
- data/lib/openehr/rm/type_name.rb +90 -0
- data/lib/openehr/rm.rb +3 -0
- data/lib/openehr/serializer/adl_serializer.rb +335 -0
- data/lib/openehr/serializer/base.rb +20 -0
- data/lib/openehr/serializer/opt_serializer.rb +49 -0
- data/lib/openehr/serializer/rm_json_serializer.rb +62 -0
- data/lib/openehr/serializer/xml_serializer.rb +260 -0
- data/lib/openehr/serializer.rb +5 -291
- data/lib/openehr/terminology_service.rb +44 -0
- data/lib/openehr/version.rb +1 -1
- data/lib/openehr.rb +5 -2
- metadata +35 -7
- data/lib/openehr/parser/validator.rb +0 -18
- data/lib/openehr/parser/xml_parser.rb +0 -13
- data/lib/openehr/rm/data_types/charset_extract.rb +0 -24
- data/lib/openehr/writer.rb +0 -12
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require_relative 'binding'
|
|
2
|
+
|
|
3
|
+
module OpenEHR
|
|
4
|
+
module AQL
|
|
5
|
+
# Walks a FromClause's containment tree against a Dataset, producing
|
|
6
|
+
# one Binding per match.
|
|
7
|
+
#
|
|
8
|
+
# An EHR root variable (bound to the Dataset::EHRRecord itself - not
|
|
9
|
+
# an OpenEHR::RM::EHR::EHR - since a Dataset element doesn't have to
|
|
10
|
+
# carry one; see Dataset's own header comment), a right-recursive
|
|
11
|
+
# CONTAINS chain searching a matched Locatable's *entire* subtree
|
|
12
|
+
# (any depth, via Pathable#path_children) for the next class,
|
|
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.
|
|
19
|
+
class ContainsResolver
|
|
20
|
+
def initialize(from_clause, dataset)
|
|
21
|
+
@root = from_clause.containment
|
|
22
|
+
@dataset = dataset
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each_binding
|
|
26
|
+
return enum_for(:each_binding) unless block_given?
|
|
27
|
+
|
|
28
|
+
@dataset.each_ehr do |ehr_record|
|
|
29
|
+
resolve(@root, ehr_record, ehr_record.compositions).each do |variables, _bound|
|
|
30
|
+
yield Binding.new(ehr_record: ehr_record, variables: variables)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Returns an Array of [variables_hash, bound_object] pairs. `pool`
|
|
38
|
+
# is the Enumerable of Pathable candidates in scope for this node:
|
|
39
|
+
# an EHR record's top-level compositions at the root, or a parent
|
|
40
|
+
# match's full recursive descendant set below a CONTAINS.
|
|
41
|
+
# `bound_object` lets the caller (a further CONTAINS) compute the
|
|
42
|
+
# next-level pool from wherever this node ended up.
|
|
43
|
+
def resolve(node, ehr_record, pool)
|
|
44
|
+
case node
|
|
45
|
+
when Model::ClassExpression
|
|
46
|
+
resolve_class_expression(node, ehr_record, pool)
|
|
47
|
+
when Model::Containment
|
|
48
|
+
resolve_containment(node, ehr_record, pool)
|
|
49
|
+
when Model::ContainmentAnd
|
|
50
|
+
resolve_containment_and(node, ehr_record, pool)
|
|
51
|
+
when Model::ContainmentOr
|
|
52
|
+
resolve_containment_or(node, ehr_record, pool)
|
|
53
|
+
else
|
|
54
|
+
raise ExecutionError, "cannot execute a #{node.class} containment yet"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_class_expression(class_expression, ehr_record, pool)
|
|
59
|
+
return [[variables_for(class_expression, ehr_record), ehr_record]] if ehr_root?(class_expression)
|
|
60
|
+
|
|
61
|
+
pool.select { |candidate| matches?(candidate, class_expression) }
|
|
62
|
+
.map { |candidate| [variables_for(class_expression, candidate), candidate] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_containment(containment, ehr_record, pool)
|
|
66
|
+
parent_matches = resolve(containment.parent, ehr_record, pool)
|
|
67
|
+
return negated_matches(containment, ehr_record, parent_matches) if containment.negated
|
|
68
|
+
|
|
69
|
+
parent_matches.flat_map do |parent_vars, parent_obj|
|
|
70
|
+
resolve(containment.child, ehr_record, descendant_pool(parent_obj, ehr_record)).map do |child_vars, child_obj|
|
|
71
|
+
[parent_vars.merge(child_vars), child_obj]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# "A NOT CONTAINS B": a parent match survives only when B has no
|
|
77
|
+
# match anywhere in that parent's subtree. B never binds a
|
|
78
|
+
# variable (there's nothing to bind an absence to).
|
|
79
|
+
def negated_matches(containment, ehr_record, parent_matches)
|
|
80
|
+
parent_matches.select do |_parent_vars, parent_obj|
|
|
81
|
+
resolve(containment.child, ehr_record, descendant_pool(parent_obj, ehr_record)).empty?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "A CONTAINS (B AND C)": both branches must match somewhere in
|
|
86
|
+
# the same pool; every combination of a B-match and a C-match
|
|
87
|
+
# becomes its own binding (AQL has no correlation between sibling
|
|
88
|
+
# branches beyond sharing a parent).
|
|
89
|
+
def resolve_containment_and(node, ehr_record, pool)
|
|
90
|
+
left_matches = resolve(node.left, ehr_record, pool)
|
|
91
|
+
return [] if left_matches.empty?
|
|
92
|
+
|
|
93
|
+
right_matches = resolve(node.right, ehr_record, pool)
|
|
94
|
+
return [] if right_matches.empty?
|
|
95
|
+
|
|
96
|
+
left_matches.flat_map do |left_vars, _left_obj|
|
|
97
|
+
right_matches.map { |right_vars, right_obj| [left_vars.merge(right_vars), right_obj] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "A CONTAINS (B OR C)": either branch matching is enough; each
|
|
102
|
+
# branch's matches contribute their own bindings independently.
|
|
103
|
+
def resolve_containment_or(node, ehr_record, pool)
|
|
104
|
+
resolve(node.left, ehr_record, pool) + resolve(node.right, ehr_record, pool)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def descendant_pool(bound_object, ehr_record)
|
|
108
|
+
return ehr_record.compositions if bound_object.equal?(ehr_record)
|
|
109
|
+
|
|
110
|
+
descendants_of(bound_object)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A subtree walk (any depth) over Pathable#path_children, matching
|
|
114
|
+
# real AQL CONTAINS semantics ("anywhere below", not just direct
|
|
115
|
+
# children).
|
|
116
|
+
def descendants_of(root)
|
|
117
|
+
results = []
|
|
118
|
+
queue = child_values(root)
|
|
119
|
+
until queue.empty?
|
|
120
|
+
node = queue.shift
|
|
121
|
+
next unless node.is_a?(OpenEHR::RM::Common::Archetyped::Pathable)
|
|
122
|
+
|
|
123
|
+
results << node
|
|
124
|
+
queue.concat(child_values(node))
|
|
125
|
+
end
|
|
126
|
+
results
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def child_values(node)
|
|
130
|
+
node.path_children.values.flat_map { |value| value.is_a?(Array) ? value : [value] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def matches?(candidate, class_expression)
|
|
134
|
+
return false unless OpenEHR::RM.subtype_of?(candidate, class_expression.class_name)
|
|
135
|
+
|
|
136
|
+
predicate_matches?(candidate, class_expression.predicate)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def predicate_matches?(candidate, predicate)
|
|
140
|
+
case predicate
|
|
141
|
+
when nil
|
|
142
|
+
true
|
|
143
|
+
when Model::ArchetypePredicate
|
|
144
|
+
candidate.respond_to?(:archetype_node_id) && candidate.archetype_node_id == predicate.archetype_id
|
|
145
|
+
else
|
|
146
|
+
raise ExecutionError, "cannot evaluate a #{predicate.class} predicate yet"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def variables_for(class_expression, matched)
|
|
151
|
+
return {} unless class_expression.variable
|
|
152
|
+
|
|
153
|
+
{ class_expression.variable => matched }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def ehr_root?(class_expression)
|
|
157
|
+
class_expression.class_name.upcase == 'EHR'
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require_relative '../errors'
|
|
2
|
+
|
|
3
|
+
# OpenEHR::AQL::Dataset is the AQL engine's only input boundary, and the
|
|
4
|
+
# whole of its framework/ORM independence: everything under
|
|
5
|
+
# lib/openehr/aql/ talks to "wherever your data lives" exclusively
|
|
6
|
+
# through this class, which itself talks to nothing but the Ruby
|
|
7
|
+
# `Enumerable`/`#each` protocol and this gem's own RM classes. No
|
|
8
|
+
# ActiveRecord/Rails/Sequel/etc. is required or referenced anywhere
|
|
9
|
+
# here or downstream - see the project plan's "Dataset:
|
|
10
|
+
# フレームワーク/ORM非依存の契約" section for the full design rationale.
|
|
11
|
+
#
|
|
12
|
+
# Construction is cheap and never iterates its source (laziness is
|
|
13
|
+
# preserved end to end); each element is normalized into an EHRRecord
|
|
14
|
+
# only as #each_ehr actually walks it.
|
|
15
|
+
#
|
|
16
|
+
# Dataset.new(ehrs:) # ehrs: anything #each-able
|
|
17
|
+
# Dataset.of_compositions(compositions, ehr_id: nil)
|
|
18
|
+
# Dataset.wrap(source) # Dataset / Composition-Enumerable / EHR-shaped-Enumerable
|
|
19
|
+
#
|
|
20
|
+
# Each element of `ehrs:` must be one of:
|
|
21
|
+
# - a Hash (symbol or string keys): {ehr_id:, compositions:, ehr_status: (optional)}
|
|
22
|
+
# - a duck-typed object responding to #ehr_id / #compositions (/ #ehr_status)
|
|
23
|
+
# - a full OpenEHR::RM::EHR::EHR (unwrapped via ehr_id.value,
|
|
24
|
+
# compositions.map { |vc| vc.latest_version.data }, and
|
|
25
|
+
# ehr_status.latest_version.data)
|
|
26
|
+
#
|
|
27
|
+
# Every element yielded by a record's compositions MUST be
|
|
28
|
+
# is_a?(OpenEHR::RM::Common::Archetyped::Pathable) - anything else
|
|
29
|
+
# raises DatasetError immediately, naming the offending class and
|
|
30
|
+
# index. This is the deliberate anti-leak mechanism that keeps loose
|
|
31
|
+
# ActiveRecord rows (or any other non-RM object) from half-working via
|
|
32
|
+
# accidental duck typing.
|
|
33
|
+
module OpenEHR
|
|
34
|
+
module AQL
|
|
35
|
+
class Dataset
|
|
36
|
+
EHRRecord = Struct.new(:ehr_id, :ehr, :ehr_status, :compositions, keyword_init: true)
|
|
37
|
+
|
|
38
|
+
def self.of_compositions(compositions, ehr_id: nil)
|
|
39
|
+
new(ehrs: [{ ehr_id: ehr_id, compositions: compositions }])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.wrap(source)
|
|
43
|
+
return source if source.is_a?(Dataset)
|
|
44
|
+
|
|
45
|
+
unless source.is_a?(Enumerable)
|
|
46
|
+
raise DatasetError, "cannot build a Dataset from a #{source.class} (expected a Dataset or an Enumerable)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
first, rest = source.first, source
|
|
50
|
+
return of_compositions(rest) if first.is_a?(OpenEHR::RM::Common::Archetyped::Pathable)
|
|
51
|
+
|
|
52
|
+
new(ehrs: rest)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(ehrs:)
|
|
56
|
+
@ehrs = ehrs
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def each_ehr
|
|
60
|
+
return enum_for(:each_ehr) unless block_given?
|
|
61
|
+
|
|
62
|
+
@ehrs.each { |element| yield normalize(element) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def normalize(element)
|
|
68
|
+
return from_ehr(element) if element.is_a?(OpenEHR::RM::EHR::EHR)
|
|
69
|
+
return from_hash(element) if element.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
from_record(element)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def from_hash(hash)
|
|
75
|
+
stringified = hash.transform_keys(&:to_s)
|
|
76
|
+
build_record(
|
|
77
|
+
ehr_id: stringified['ehr_id'],
|
|
78
|
+
ehr: nil,
|
|
79
|
+
ehr_status: stringified['ehr_status'],
|
|
80
|
+
compositions: stringified['compositions']
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def from_record(record)
|
|
85
|
+
build_record(
|
|
86
|
+
ehr_id: record.ehr_id,
|
|
87
|
+
ehr: nil,
|
|
88
|
+
ehr_status: record.respond_to?(:ehr_status) ? record.ehr_status : nil,
|
|
89
|
+
compositions: record.compositions
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def from_ehr(ehr)
|
|
94
|
+
build_record(
|
|
95
|
+
ehr_id: ehr.ehr_id,
|
|
96
|
+
ehr: ehr,
|
|
97
|
+
ehr_status: ehr.ehr_status&.latest_version&.data,
|
|
98
|
+
compositions: ehr.compositions.map { |versioned_composition| versioned_composition.latest_version.data }
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_record(ehr_id:, ehr:, ehr_status:, compositions:)
|
|
103
|
+
compositions = Array(compositions)
|
|
104
|
+
compositions.each_with_index do |composition, index|
|
|
105
|
+
next if composition.is_a?(OpenEHR::RM::Common::Archetyped::Pathable)
|
|
106
|
+
|
|
107
|
+
raise DatasetError,
|
|
108
|
+
"Dataset compositions must be OpenEHR::RM RM objects (Pathable), " \
|
|
109
|
+
"got #{composition.class} at index #{index}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
EHRRecord.new(ehr_id: unwrap_id(ehr_id), ehr: ehr, ehr_status: ehr_status, compositions: compositions)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unwrap_id(id)
|
|
116
|
+
return nil if id.nil?
|
|
117
|
+
|
|
118
|
+
id.respond_to?(:value) ? id.value : id
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
module OpenEHR
|
|
2
|
+
module AQL
|
|
3
|
+
# Evaluates a columnExpr/terminal Model node against a Binding.
|
|
4
|
+
#
|
|
5
|
+
# SELECT paths ("o/data[at0001]/.../value/magnitude", "c/name/value")
|
|
6
|
+
# walk one segment at a time: as long as the current value is
|
|
7
|
+
# Pathable AND the segment names one of its *declared*
|
|
8
|
+
# `path_attribute`s (the content-structure attributes PATHABLE
|
|
9
|
+
# navigation was built for - see lib/openehr/rm/common/archetyped.rb),
|
|
10
|
+
# the hop goes through PATHABLE#items_at_path (reusing its predicate
|
|
11
|
+
# matching for node/archetype predicates). Everything else - RM
|
|
12
|
+
# metadata that isn't part of that DSL (name, archetype_details,
|
|
13
|
+
# composer, ...) and genuinely non-Pathable values (a DV_QUANTITY's
|
|
14
|
+
# magnitude, a DV_TEXT's value) - is a plain attribute read via a
|
|
15
|
+
# whitelisted public_send, never an arbitrary send driven by query
|
|
16
|
+
# text. A predicate directly on an identifiedPath's own variable
|
|
17
|
+
# (e.g. "c[at001]/..."), parameters and function calls are added by
|
|
18
|
+
# later engine milestones.
|
|
19
|
+
module PathEvaluator
|
|
20
|
+
# Attribute hops not reachable through the path_attribute DSL.
|
|
21
|
+
# Expand only when a real query needs another one - see the
|
|
22
|
+
# project's "no arbitrary send" rule in the class comment above.
|
|
23
|
+
ALLOWED_TERMINAL_HOPS = %w[magnitude name value].freeze
|
|
24
|
+
|
|
25
|
+
# Dataset::EHRRecord#ehr_id is already an unwrapped String (see
|
|
26
|
+
# Dataset's own header comment), but AQL's "e/ehr_id/value" syntax
|
|
27
|
+
# still expects a HierObjectID-shaped hop with its own #value -
|
|
28
|
+
# this tiny wrapper bridges that gap.
|
|
29
|
+
EhrIdValue = Struct.new(:value)
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def evaluate(expression, binding)
|
|
34
|
+
case expression
|
|
35
|
+
when Model::IdentifiedPath
|
|
36
|
+
evaluate_identified_path(expression, binding)
|
|
37
|
+
when Model::Literal
|
|
38
|
+
expression.value
|
|
39
|
+
else
|
|
40
|
+
raise ExecutionError, "cannot evaluate a #{expression.class} yet"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def evaluate_identified_path(path, binding)
|
|
45
|
+
raise ExecutionError, 'predicates on a SELECT variable are not yet supported' if path.predicate
|
|
46
|
+
|
|
47
|
+
value = binding[path.variable]
|
|
48
|
+
return value unless path.path
|
|
49
|
+
|
|
50
|
+
path.path.segments.reduce(value) do |current, segment|
|
|
51
|
+
current.nil? ? nil : navigate(current, segment)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def navigate(current, segment)
|
|
56
|
+
return navigate_ehr_record(current, segment) if current.is_a?(Dataset::EHRRecord)
|
|
57
|
+
|
|
58
|
+
if declared_path_attribute?(current, segment)
|
|
59
|
+
navigate_pathable(current, segment)
|
|
60
|
+
else
|
|
61
|
+
navigate_terminal(current, segment)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "e/ehr_id/value" and "e/ehr_status/..." always resolve (from
|
|
66
|
+
# Dataset's own record shape); any other "e/..." path falls
|
|
67
|
+
# through to whatever full RM::EHR::EHR the record carries, if
|
|
68
|
+
# any, else resolves to nil rather than erroring - AQL's
|
|
69
|
+
# path-absent-means-null semantics again, not a missing feature.
|
|
70
|
+
def navigate_ehr_record(record, segment)
|
|
71
|
+
raise ExecutionError, "path predicates on an EHR root are not supported (#{segment.attribute})" if segment.predicate
|
|
72
|
+
|
|
73
|
+
case segment.attribute
|
|
74
|
+
when 'ehr_id' then EhrIdValue.new(record.ehr_id)
|
|
75
|
+
when 'ehr_status' then record.ehr_status
|
|
76
|
+
else
|
|
77
|
+
record.ehr.nil? ? nil : navigate(record.ehr, segment)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def declared_path_attribute?(current, segment)
|
|
82
|
+
current.is_a?(OpenEHR::RM::Common::Archetyped::Pathable) &&
|
|
83
|
+
current.class.path_attributes.map(&:to_s).include?(segment.attribute)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A path segment matching nothing is a legitimate "no value here"
|
|
87
|
+
# (AQL's path-absent-means-null semantics, same convention as
|
|
88
|
+
# PATHABLE#item_at_path itself), not an error - only an
|
|
89
|
+
# *ambiguous* match (more than one item) is.
|
|
90
|
+
def navigate_pathable(current, segment)
|
|
91
|
+
matches = current.items_at_path(rm_path_for(segment))
|
|
92
|
+
case matches.size
|
|
93
|
+
when 0 then nil
|
|
94
|
+
when 1 then matches.first
|
|
95
|
+
else raise ExecutionError, "path segment #{segment.to_s.inspect} matched #{matches.size} items " \
|
|
96
|
+
'(fan-out SELECT paths are not yet supported)'
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def navigate_terminal(current, segment)
|
|
101
|
+
raise ExecutionError, "path predicates on a non-Pathable value are not supported (#{segment.attribute})" if segment.predicate
|
|
102
|
+
unless ALLOWED_TERMINAL_HOPS.include?(segment.attribute)
|
|
103
|
+
raise ExecutionError, "unsupported path attribute #{segment.attribute.inspect} on a #{current.class}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
current.public_send(segment.attribute)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def rm_path_for(segment)
|
|
110
|
+
return "/#{segment.attribute}" unless segment.predicate
|
|
111
|
+
|
|
112
|
+
"/#{segment.attribute}[#{predicate_path_text(segment.predicate)}]"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def predicate_path_text(predicate)
|
|
116
|
+
case predicate
|
|
117
|
+
when Model::ArchetypePredicate
|
|
118
|
+
predicate.archetype_id
|
|
119
|
+
when Model::NodePredicate
|
|
120
|
+
node_predicate_path_text(predicate)
|
|
121
|
+
else
|
|
122
|
+
raise ExecutionError, "cannot evaluate a #{predicate.class} path predicate yet"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def node_predicate_path_text(predicate)
|
|
127
|
+
return predicate.code unless predicate.value
|
|
128
|
+
|
|
129
|
+
unless predicate.value.is_a?(String)
|
|
130
|
+
raise ExecutionError, "cannot evaluate a #{predicate.value.class} node predicate value yet"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
"#{predicate.code}, '#{predicate.value}'"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module OpenEHR
|
|
2
|
+
module AQL
|
|
3
|
+
# Evaluates a WHERE clause's boolean expression tree against a
|
|
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.
|
|
8
|
+
module PredicateEvaluator
|
|
9
|
+
COMPARATORS = {
|
|
10
|
+
'=' => :==, '!=' => :!=, '<' => :<, '<=' => :<=, '>' => :>, '>=' => :>=
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# `expression` is nil when there is no WHERE clause at all.
|
|
16
|
+
def matches?(expression, binding, params)
|
|
17
|
+
case expression
|
|
18
|
+
when nil
|
|
19
|
+
true
|
|
20
|
+
when Model::Comparison
|
|
21
|
+
evaluate_comparison(expression, binding, params)
|
|
22
|
+
when Model::AndExpr
|
|
23
|
+
matches?(expression.left, binding, params) && matches?(expression.right, binding, params)
|
|
24
|
+
when Model::OrExpr
|
|
25
|
+
matches?(expression.left, binding, params) || matches?(expression.right, binding, params)
|
|
26
|
+
when Model::NotExpr
|
|
27
|
+
!matches?(expression.operand, binding, params)
|
|
28
|
+
when Model::ExistsExpr
|
|
29
|
+
!PathEvaluator.evaluate(expression.path, binding).nil?
|
|
30
|
+
else
|
|
31
|
+
raise ExecutionError, "cannot evaluate a #{expression.class} WHERE expression yet"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A comparison against an absent (nil) value is neither true nor
|
|
36
|
+
# false in AQL/SQL terms - it simply fails to select the row, the
|
|
37
|
+
# same as SQL's NULL-comparison-is-UNKNOWN convention.
|
|
38
|
+
def evaluate_comparison(comparison, binding, params)
|
|
39
|
+
left = resolve_operand(comparison.left, binding, params)
|
|
40
|
+
right = resolve_operand(comparison.right, binding, params)
|
|
41
|
+
return false if left.nil? || right.nil?
|
|
42
|
+
|
|
43
|
+
compare(left, comparison.operator, right)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_operand(node, binding, params)
|
|
47
|
+
return lookup_param(node, params) if node.is_a?(Model::Parameter)
|
|
48
|
+
|
|
49
|
+
PathEvaluator.evaluate(node, binding)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def lookup_param(parameter, params)
|
|
53
|
+
return params[parameter.name.to_sym] if params.key?(parameter.name.to_sym)
|
|
54
|
+
return params[parameter.name] if params.key?(parameter.name)
|
|
55
|
+
|
|
56
|
+
raise UnboundParameterError, "unbound parameter: $#{parameter.name}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compare(left, operator, right)
|
|
60
|
+
method = COMPARATORS.fetch(operator) { raise ExecutionError, "unknown comparison operator #{operator.inspect}" }
|
|
61
|
+
left.public_send(method, right)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require_relative 'engine/binding'
|
|
2
|
+
require_relative 'engine/contains_resolver'
|
|
3
|
+
require_relative 'engine/path_evaluator'
|
|
4
|
+
require_relative 'engine/predicate_evaluator'
|
|
5
|
+
require_relative 'result_set'
|
|
6
|
+
|
|
7
|
+
module OpenEHR
|
|
8
|
+
module AQL
|
|
9
|
+
# Orchestrates one Query execution against a Dataset, in the order
|
|
10
|
+
# CONTAINS -> WHERE -> ORDER BY -> SELECT -> DISTINCT -> LIMIT/OFFSET.
|
|
11
|
+
#
|
|
12
|
+
# A SELECT clause made entirely of aggregate columns
|
|
13
|
+
# (Model::AggregateFunctionCall) collapses the whole (post-WHERE)
|
|
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.
|
|
18
|
+
class Engine
|
|
19
|
+
def initialize(query)
|
|
20
|
+
@query = query
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute(dataset, params: {})
|
|
24
|
+
bindings = ContainsResolver.new(@query.from_clause, Dataset.wrap(dataset)).each_binding
|
|
25
|
+
.select { |binding| where_matches?(binding, params) }
|
|
26
|
+
rows = aggregate_query? ? [aggregate_row(bindings)] : select_rows(bindings)
|
|
27
|
+
ResultSet.new(columns: @query.select_clause.columns.map { |column| column_name(column) }, rows: rows)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def where_matches?(binding, params)
|
|
33
|
+
PredicateEvaluator.matches?(@query.where_clause&.expression, binding, params)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def select_rows(bindings)
|
|
37
|
+
rows = project(order(bindings))
|
|
38
|
+
rows = rows.uniq if @query.select_clause.distinct
|
|
39
|
+
apply_limit(rows)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def order(bindings)
|
|
43
|
+
order_by = @query.order_by_clause
|
|
44
|
+
return bindings unless order_by
|
|
45
|
+
|
|
46
|
+
bindings.sort { |a, b| compare_bindings(a, b, order_by.items) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compare_bindings(a, b, order_by_items)
|
|
50
|
+
order_by_items.each do |item|
|
|
51
|
+
cmp = compare_values(PathEvaluator.evaluate(item.path, a), PathEvaluator.evaluate(item.path, b))
|
|
52
|
+
cmp = -cmp if item.direction == :desc
|
|
53
|
+
return cmp unless cmp.zero?
|
|
54
|
+
end
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# nil (an absent path) sorts after any real value, regardless of
|
|
59
|
+
# ASC/DESC - the usual SQL "NULLS LAST" convention.
|
|
60
|
+
def compare_values(a, b)
|
|
61
|
+
return 0 if a.nil? && b.nil?
|
|
62
|
+
return 1 if a.nil?
|
|
63
|
+
return -1 if b.nil?
|
|
64
|
+
|
|
65
|
+
a <=> b
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def project(bindings)
|
|
69
|
+
columns = @query.select_clause.columns
|
|
70
|
+
bindings.map { |binding| columns.map { |column| PathEvaluator.evaluate(column.expression, binding) } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_limit(rows)
|
|
74
|
+
limit_clause = @query.limit_clause
|
|
75
|
+
return rows unless limit_clause
|
|
76
|
+
|
|
77
|
+
rows.drop(limit_clause.offset).take(limit_clause.limit)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def aggregate_query?
|
|
81
|
+
@query.select_clause.columns.any? { |column| column.expression.is_a?(Model::AggregateFunctionCall) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def aggregate_row(bindings)
|
|
85
|
+
@query.select_clause.columns.map do |column|
|
|
86
|
+
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)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def evaluate_aggregate(call, bindings)
|
|
96
|
+
return bindings.size if call.name == :count && call.path.nil?
|
|
97
|
+
|
|
98
|
+
values = bindings.map { |binding| PathEvaluator.evaluate(call.path, binding) }.compact
|
|
99
|
+
values = values.uniq if call.distinct
|
|
100
|
+
|
|
101
|
+
case call.name
|
|
102
|
+
when :count then values.size
|
|
103
|
+
when :min then values.min
|
|
104
|
+
when :max then values.max
|
|
105
|
+
when :sum then values.empty? ? nil : values.sum
|
|
106
|
+
when :avg then values.empty? ? nil : values.sum.fdiv(values.size)
|
|
107
|
+
else raise ExecutionError, "cannot evaluate a #{call.name.inspect} aggregate function yet"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def column_name(column)
|
|
112
|
+
return column.alias_name if column.alias_name
|
|
113
|
+
|
|
114
|
+
expression = column.expression
|
|
115
|
+
expression.is_a?(Model::IdentifiedPath) ? expression.variable : expression.class.name
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Error hierarchy for the AQL subsystem (parsing, semantic checks, and
|
|
2
|
+
# execution). Kept in its own file so lexer.rb/parser.rb/engine.rb can
|
|
3
|
+
# each require just this, without pulling in the rest of the subsystem.
|
|
4
|
+
module OpenEHR
|
|
5
|
+
module AQL
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised by the Lexer/Parser on malformed AQL source. Carries the
|
|
9
|
+
# 1-based line/column of the offending token so the message can point
|
|
10
|
+
# at the exact spot, e.g. "line 2, column 8: ...".
|
|
11
|
+
class ParseError < Error
|
|
12
|
+
attr_reader :line, :column
|
|
13
|
+
|
|
14
|
+
def initialize(message, line: nil, column: nil)
|
|
15
|
+
@line = line
|
|
16
|
+
@column = column
|
|
17
|
+
location = line && column ? " (line #{line}, column #{column})" : ''
|
|
18
|
+
super("#{message}#{location}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class SemanticError < Error; end
|
|
23
|
+
class UnboundParameterError < Error; end
|
|
24
|
+
class ExecutionError < Error; end
|
|
25
|
+
class DatasetError < Error; end
|
|
26
|
+
end
|
|
27
|
+
end
|