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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.rdoc +95 -14
  3. data/lib/openehr/am/archetype/constraint_model/primitive.rb +204 -6
  4. data/lib/openehr/am/archetype/constraint_model.rb +228 -4
  5. data/lib/openehr/am/archetype/ontology.rb +2 -2
  6. data/lib/openehr/am/archetype.rb +101 -2
  7. data/lib/openehr/am/openehr_profile/data_types/basic.rb +28 -0
  8. data/lib/openehr/am/openehr_profile/data_types/quantity.rb +88 -0
  9. data/lib/openehr/am/openehr_profile/data_types/text.rb +23 -0
  10. data/lib/openehr/am/template.rb +77 -24
  11. data/lib/openehr/aql/engine/binding.rb +13 -0
  12. data/lib/openehr/aql/engine/contains_resolver.rb +161 -0
  13. data/lib/openehr/aql/engine/dataset.rb +122 -0
  14. data/lib/openehr/aql/engine/path_evaluator.rb +137 -0
  15. data/lib/openehr/aql/engine/predicate_evaluator.rb +65 -0
  16. data/lib/openehr/aql/engine.rb +119 -0
  17. data/lib/openehr/aql/errors.rb +27 -0
  18. data/lib/openehr/aql/lexer.rb +295 -0
  19. data/lib/openehr/aql/model/containment.rb +45 -0
  20. data/lib/openehr/aql/model/from_clause.rb +35 -0
  21. data/lib/openehr/aql/model/function_call.rb +56 -0
  22. data/lib/openehr/aql/model/identified_path.rb +20 -0
  23. data/lib/openehr/aql/model/literal.rb +15 -0
  24. data/lib/openehr/aql/model/object_path.rb +34 -0
  25. data/lib/openehr/aql/model/order_by_and_limit.rb +50 -0
  26. data/lib/openehr/aql/model/predicate.rb +84 -0
  27. data/lib/openehr/aql/model/query.rb +24 -0
  28. data/lib/openehr/aql/model/select_clause.rb +29 -0
  29. data/lib/openehr/aql/model/where_clause.rb +105 -0
  30. data/lib/openehr/aql/model.rb +14 -0
  31. data/lib/openehr/aql/parser.rb +484 -0
  32. data/lib/openehr/aql/result_set.rb +54 -0
  33. data/lib/openehr/aql.rb +28 -0
  34. data/lib/openehr/assumed_library_types.rb +68 -17
  35. data/lib/openehr/parser/adl_grammar.tt +29 -13
  36. data/lib/openehr/parser/adl_parser.rb +18 -4
  37. data/lib/openehr/parser/archetype_validator.rb +126 -0
  38. data/lib/openehr/parser/exception.rb +13 -0
  39. data/lib/openehr/parser/opt_parser.rb +306 -27
  40. data/lib/openehr/parser.rb +2 -0
  41. data/lib/openehr/path.rb +195 -0
  42. data/lib/openehr/rm/common/archetyped.rb +117 -7
  43. data/lib/openehr/rm/common/change_control.rb +3 -8
  44. data/lib/openehr/rm/common/directory.rb +4 -0
  45. data/lib/openehr/rm/common/generic.rb +24 -10
  46. data/lib/openehr/rm/composition/content/entry.rb +24 -5
  47. data/lib/openehr/rm/composition/content/navigation.rb +2 -1
  48. data/lib/openehr/rm/composition.rb +19 -2
  49. data/lib/openehr/rm/data_structures/history.rb +3 -0
  50. data/lib/openehr/rm/data_structures/item_structure/representation.rb +13 -9
  51. data/lib/openehr/rm/data_structures/item_structure.rb +28 -15
  52. data/lib/openehr/rm/data_types/encapsulated.rb +23 -3
  53. data/lib/openehr/rm/data_types/quantity/date_time.rb +9 -1
  54. data/lib/openehr/rm/data_types/quantity.rb +66 -14
  55. data/lib/openehr/rm/data_types/text.rb +8 -0
  56. data/lib/openehr/rm/data_types/time_specification.rb +5 -4
  57. data/lib/openehr/rm/data_types/uri.rb +0 -1
  58. data/lib/openehr/rm/demographic.rb +4 -3
  59. data/lib/openehr/rm/ehr.rb +20 -1
  60. data/lib/openehr/rm/factory.rb +345 -36
  61. data/lib/openehr/rm/integration.rb +1 -0
  62. data/lib/openehr/rm/support/identification.rb +37 -8
  63. data/lib/openehr/rm/support/measurement.rb +32 -0
  64. data/lib/openehr/rm/type_name.rb +90 -0
  65. data/lib/openehr/rm.rb +3 -0
  66. data/lib/openehr/serializer/adl_serializer.rb +335 -0
  67. data/lib/openehr/serializer/base.rb +20 -0
  68. data/lib/openehr/serializer/opt_serializer.rb +49 -0
  69. data/lib/openehr/serializer/rm_json_serializer.rb +62 -0
  70. data/lib/openehr/serializer/xml_serializer.rb +260 -0
  71. data/lib/openehr/serializer.rb +5 -291
  72. data/lib/openehr/terminology_service.rb +44 -0
  73. data/lib/openehr/version.rb +1 -1
  74. data/lib/openehr.rb +5 -2
  75. metadata +36 -11
  76. data/lib/openehr/parser/validator.rb +0 -18
  77. data/lib/openehr/parser/xml_parser.rb +0 -13
  78. data/lib/openehr/rm/data_types/charset_extract.rb +0 -24
  79. data/lib/openehr/writer.rb +0 -12
@@ -1,49 +1,102 @@
1
+ require_relative 'archetype'
2
+
1
3
  module OpenEHR
2
4
  module AM
3
5
  module Template
4
- class OperationalTemplate
5
- attr_reader :uid, :concept, :language, :description, :template_id, :definition, :component_terminologies
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
- self.uid = args[:uid]
9
- self.concept = args[:concept]
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
- self.language = args[:language]
12
- self.description = args[:description]
13
- self.definition = args[:definition]
14
- self.component_terminologies = args[:component_terminologies]
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 uid=(uid)
18
- @uid = uid
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
- def language=(language)
27
- @language = language
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 template_id=(template_id)
31
- raise ArgumentError if template_id.nil?
32
- @template_id = template_id
56
+ def component_terminologies=(component_terminologies)
57
+ @component_terminologies = component_terminologies || {}
33
58
  end
34
59
 
35
- def description=(description)
36
- raise ArgumentError if description.nil?
37
- @description=description
60
+ def terminology_extracts=(terminology_extracts)
61
+ @terminology_extracts = terminology_extracts || {}
38
62
  end
39
63
 
40
- def definition=(definition)
41
- raise ArgumentError if definition.nil?
42
- @definition = definition
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
- def component_terminologies=(component_terminologies)
46
- @component_terminologies = component_terminologies
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