openehr 1.2.999999 → 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 +95 -14
- 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 +77 -24
- 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 +306 -27
- data/lib/openehr/parser.rb +2 -0
- data/lib/openehr/path.rb +195 -0
- data/lib/openehr/rm/common/archetyped.rb +117 -7
- 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/data_types/uri.rb +0 -1
- data/lib/openehr/rm/demographic.rb +4 -3
- data/lib/openehr/rm/ehr.rb +20 -1
- data/lib/openehr/rm/factory.rb +345 -36
- 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 +36 -11
- 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
data/lib/openehr/am/template.rb
CHANGED
|
@@ -1,49 +1,102 @@
|
|
|
1
|
+
require_relative 'archetype'
|
|
2
|
+
|
|
1
3
|
module OpenEHR
|
|
2
4
|
module AM
|
|
3
5
|
module Template
|
|
4
|
-
class
|
|
5
|
-
|
|
6
|
+
# OPERATIONAL_TEMPLATE class represents a "compiled" template derived from
|
|
7
|
+
# a source TEMPLATE by a "flattening" process, used for generating and
|
|
8
|
+
# validating reference model instance data.
|
|
9
|
+
# According to openEHR AOM2 specification, it inherits from AUTHORED_ARCHETYPE.
|
|
10
|
+
class OperationalTemplate < OpenEHR::AM::Archetype::Archetype
|
|
11
|
+
attr_reader :component_terminologies, :terminology_extracts, :template_id
|
|
6
12
|
|
|
7
13
|
def initialize(args = {})
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
# template_id must be validated before delegating to Archetype#initialize:
|
|
15
|
+
# OPERATIONAL_TEMPLATE identifies itself by template_id, and archetype_id
|
|
16
|
+
# is derived from it below, so an absent template_id must fail with a
|
|
17
|
+
# template-specific message rather than falling through to Archetype's
|
|
18
|
+
# own (unrelated) mandatory-field checks.
|
|
10
19
|
self.template_id = args[:template_id]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
|
|
21
|
+
# Initialize parent archetype with template-specific archetype_id
|
|
22
|
+
template_args = args.dup
|
|
23
|
+
template_args[:archetype_id] = args[:template_id] if args[:template_id] && !args[:archetype_id]
|
|
24
|
+
|
|
25
|
+
super(template_args)
|
|
26
|
+
|
|
27
|
+
self.component_terminologies = args[:component_terminologies] || {}
|
|
28
|
+
self.terminology_extracts = args[:terminology_extracts] || {}
|
|
15
29
|
end
|
|
16
30
|
|
|
17
|
-
def
|
|
18
|
-
|
|
31
|
+
def template_id=(template_id)
|
|
32
|
+
if template_id.nil?
|
|
33
|
+
raise ArgumentError, 'template_id is mandatory for operational template'
|
|
34
|
+
end
|
|
35
|
+
@template_id = template_id
|
|
36
|
+
# Update archetype_id to match template_id for consistency
|
|
37
|
+
@archetype_id = template_id if template_id
|
|
19
38
|
end
|
|
20
39
|
|
|
40
|
+
# An operational template's concept is commonly derived from its
|
|
41
|
+
# archetype_id (see #concept below) rather than supplied explicitly,
|
|
42
|
+
# so unlike Archetype#concept= it must not require a value up front.
|
|
21
43
|
def concept=(concept)
|
|
22
|
-
raise ArgumentError if concept.nil?
|
|
23
44
|
@concept = concept
|
|
24
45
|
end
|
|
25
46
|
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
# AuthoredResource#description has no mandatory check, but an
|
|
48
|
+
# operational template always carries its own resource description.
|
|
49
|
+
def description=(description)
|
|
50
|
+
if description.nil?
|
|
51
|
+
raise ArgumentError, 'description is mandatory'
|
|
52
|
+
end
|
|
53
|
+
@description = description
|
|
28
54
|
end
|
|
29
55
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
@template_id = template_id
|
|
56
|
+
def component_terminologies=(component_terminologies)
|
|
57
|
+
@component_terminologies = component_terminologies || {}
|
|
33
58
|
end
|
|
34
59
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
@description=description
|
|
60
|
+
def terminology_extracts=(terminology_extracts)
|
|
61
|
+
@terminology_extracts = terminology_extracts || {}
|
|
38
62
|
end
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
# Returns true if this is a valid operational template
|
|
65
|
+
def is_valid_operational_template?
|
|
66
|
+
return false if template_id.nil?
|
|
67
|
+
return false if definition.nil?
|
|
68
|
+
return false if component_terminologies.nil?
|
|
69
|
+
true
|
|
43
70
|
end
|
|
44
71
|
|
|
45
|
-
|
|
46
|
-
|
|
72
|
+
# Returns whether the template is specialized (should be true for operational templates)
|
|
73
|
+
def is_specialized?
|
|
74
|
+
!parent_archetype_id.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get all archetype identifiers referenced in this operational template
|
|
78
|
+
def referenced_archetype_ids
|
|
79
|
+
component_terminologies.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get terminology for a specific archetype
|
|
83
|
+
def terminology_for_archetype(archetype_id)
|
|
84
|
+
component_terminologies[archetype_id]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Override concept to use template concept or fallback to archetype concept
|
|
88
|
+
def concept
|
|
89
|
+
@concept || (archetype_id ? archetype_id.concept_name : nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Compatibility method for backward compatibility with existing tests
|
|
93
|
+
def language
|
|
94
|
+
original_language
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Compatibility setter for language (maps to original_language)
|
|
98
|
+
def language=(language)
|
|
99
|
+
self.original_language = language
|
|
47
100
|
end
|
|
48
101
|
end
|
|
49
102
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module OpenEHR
|
|
2
|
+
module AQL
|
|
3
|
+
# A single row-candidate produced while walking the FROM clause's
|
|
4
|
+
# containment tree: the Dataset::EHRRecord it came from (for "e/..."
|
|
5
|
+
# root paths) plus the RM objects bound to each FROM variable so far
|
|
6
|
+
# (e.g. {"c" => a_composition, "o" => an_observation}).
|
|
7
|
+
Binding = Struct.new(:ehr_record, :variables, keyword_init: true) do
|
|
8
|
+
def [](name)
|
|
9
|
+
variables.fetch(name) { raise SemanticError, "unbound variable: #{name.inspect}" }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -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
|