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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.rdoc +93 -12
  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 +25 -3
  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 +162 -9
  40. data/lib/openehr/parser.rb +2 -0
  41. data/lib/openehr/path.rb +195 -0
  42. data/lib/openehr/rm/common/archetyped.rb +116 -6
  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/demographic.rb +4 -3
  58. data/lib/openehr/rm/ehr.rb +20 -1
  59. data/lib/openehr/rm/factory.rb +234 -6
  60. data/lib/openehr/rm/integration.rb +1 -0
  61. data/lib/openehr/rm/support/identification.rb +37 -8
  62. data/lib/openehr/rm/support/measurement.rb +32 -0
  63. data/lib/openehr/rm/type_name.rb +90 -0
  64. data/lib/openehr/rm.rb +3 -0
  65. data/lib/openehr/serializer/adl_serializer.rb +335 -0
  66. data/lib/openehr/serializer/base.rb +20 -0
  67. data/lib/openehr/serializer/opt_serializer.rb +49 -0
  68. data/lib/openehr/serializer/rm_json_serializer.rb +62 -0
  69. data/lib/openehr/serializer/xml_serializer.rb +260 -0
  70. data/lib/openehr/serializer.rb +5 -291
  71. data/lib/openehr/terminology_service.rb +44 -0
  72. data/lib/openehr/version.rb +1 -1
  73. data/lib/openehr.rb +5 -2
  74. metadata +35 -7
  75. data/lib/openehr/parser/validator.rb +0 -18
  76. data/lib/openehr/parser/xml_parser.rb +0 -13
  77. data/lib/openehr/rm/data_types/charset_extract.rb +0 -24
  78. 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