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
@@ -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
@@ -0,0 +1,295 @@
1
+ require_relative 'errors'
2
+
3
+ # OpenEHR::AQL::Lexer tokenizes AQL source text, following the official
4
+ # ANTLR4 lexer grammar (AqlLexer.g4,
5
+ # https://specifications.openehr.org/releases/QUERY/latest/AQL.html) as
6
+ # the reference spec. It is a hand-written scanner (no parser-generator
7
+ # dependency is added) so that maximal-munch tie-breaks between
8
+ # overlapping token shapes - e.g. "openEHR-EHR-OBSERVATION.blood_pressure.v1"
9
+ # (ARCHETYPE_HRID) vs "openEHR" (IDENTIFIER), or "id5" (ID_CODE) vs "id5x"
10
+ # (IDENTIFIER) - can be resolved explicitly per token category, matching
11
+ # ANTLR's longest-match-wins / first-rule-wins-on-tie semantics.
12
+ #
13
+ # Scope (M0 milestone): keywords (case-insensitive), identifiers,
14
+ # ARCHETYPE_HRID, AT_CODE/ID_CODE, STRING, INTEGER/REAL/SCI_*, BOOLEAN,
15
+ # $PARAMETER, comparison operators, structural symbols, and '--' comments.
16
+ # DATE/TIME/DATETIME literals, TERM_CODE, URI, CONTAINED_REGEX and a
17
+ # Unicode BOM are deferred to the milestone that first needs them - none
18
+ # of the official AQL examples this gem targets require them yet.
19
+ module OpenEHR
20
+ module AQL
21
+ class Token
22
+ attr_reader :type, :value, :line, :column
23
+
24
+ def initialize(type, value, line:, column:)
25
+ @type = type
26
+ @value = value
27
+ @line = line
28
+ @column = column
29
+ freeze
30
+ end
31
+
32
+ def ==(other)
33
+ other.is_a?(Token) && type == other.type && value == other.value
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [type, value].hash
39
+ end
40
+
41
+ def to_s
42
+ "#{type}(#{value.inspect})@#{line}:#{column}"
43
+ end
44
+ end
45
+
46
+ class Lexer
47
+ KEYWORDS = {
48
+ 'select' => :select, 'as' => :as, 'from' => :from, 'where' => :where,
49
+ 'order' => :order, 'by' => :by, 'desc' => :desc, 'descending' => :descending,
50
+ 'asc' => :asc, 'ascending' => :ascending, 'limit' => :limit, 'offset' => :offset,
51
+ 'distinct' => :distinct, 'version' => :version, 'latest_version' => :latest_version,
52
+ 'all_versions' => :all_versions, 'null' => :null,
53
+ 'top' => :top, 'forward' => :forward, 'backward' => :backward,
54
+ 'contains' => :contains, 'and' => :and, 'or' => :or, 'not' => :not, 'exists' => :exists,
55
+ 'like' => :like, 'matches' => :matches,
56
+ 'count' => :count, 'min' => :min, 'max' => :max, 'sum' => :sum, 'avg' => :avg,
57
+ 'terminology' => :terminology,
58
+ 'length' => :length, 'position' => :position, 'substring' => :substring,
59
+ 'concat' => :concat, 'concat_ws' => :concat_ws,
60
+ 'abs' => :abs, 'mod' => :mod, 'ceil' => :ceil, 'floor' => :floor, 'round' => :round,
61
+ 'now' => :now, 'current_date' => :current_date, 'current_time' => :current_time,
62
+ 'current_date_time' => :current_date_time, 'current_timezone' => :current_timezone
63
+ }.freeze
64
+
65
+ ARCHETYPE_HRID_RE = /\A[A-Za-z][A-Za-z0-9_]*-[A-Za-z][A-Za-z0-9_]*-[A-Za-z][A-Za-z0-9_]*
66
+ \.[A-Za-z][A-Za-z0-9_-]*
67
+ \.v\d+(?:\.\d+)*(?:-(?:rc|alpha)(?:\.\d+)?)?/x
68
+ AT_ID_CODE_RE = /\A(?:at|id)\d+(?:\.\d+)*/
69
+ IDENTIFIER_RE = /\A[A-Za-z][A-Za-z0-9_]*/
70
+ URI_CHAR = %r{[A-Za-z0-9\-._~%!$&'()*+,;=:@/]}.source
71
+ URI_RE = %r{\A[A-Za-z][A-Za-z0-9+.-]*:(?://)?#{URI_CHAR}*(?:\?#{URI_CHAR}*)?(?:\##{URI_CHAR}*)?}o
72
+ NUMBER_RE = /\A(?:\d*\.\d+|\d+)(?:[eE][+-]?\d+)?/
73
+
74
+ ESCAPES = {
75
+ '\\' => "\\", "'" => "'", '"' => '"', '?' => '?',
76
+ 'a' => "\a", 'b' => "\b", 'f' => "\f", 'n' => "\n",
77
+ 'r' => "\r", 't' => "\t", 'v' => "\v"
78
+ }.freeze
79
+
80
+ SYMBOLS = {
81
+ ',' => :comma, '/' => :slash, '*' => :asterisk, '+' => :plus,
82
+ '(' => :left_paren, ')' => :right_paren,
83
+ '[' => :left_bracket, ']' => :right_bracket,
84
+ '{' => :left_curly, '}' => :right_curly
85
+ }.freeze
86
+
87
+ def initialize(source)
88
+ raise ArgumentError, 'source must be a String' unless source.is_a?(String)
89
+
90
+ @chars = source.chars
91
+ @pos = 0
92
+ @line = 1
93
+ @column = 1
94
+ end
95
+
96
+ def tokenize
97
+ tokens = []
98
+ loop do
99
+ skip_whitespace_and_comments
100
+ start_line, start_column = @line, @column
101
+ if at_end?
102
+ tokens << Token.new(:eof, nil, line: start_line, column: start_column)
103
+ break
104
+ end
105
+ tokens << next_token(start_line, start_column)
106
+ end
107
+ tokens.freeze
108
+ end
109
+
110
+ private
111
+
112
+ def at_end?
113
+ @pos >= @chars.length
114
+ end
115
+
116
+ def peek(offset = 0)
117
+ @chars[@pos + offset]
118
+ end
119
+
120
+ def advance
121
+ c = @chars[@pos]
122
+ @pos += 1
123
+ if c == "\n"
124
+ @line += 1
125
+ @column = 1
126
+ else
127
+ @column += 1
128
+ end
129
+ c
130
+ end
131
+
132
+ def remaining
133
+ @chars[@pos..].join
134
+ end
135
+
136
+ def skip_whitespace_and_comments
137
+ loop do
138
+ if !at_end? && peek =~ /[ \t\r\n]/
139
+ advance
140
+ elsif peek == '-' && peek(1) == '-' && comment_ahead?
141
+ skip_comment
142
+ else
143
+ break
144
+ end
145
+ end
146
+ end
147
+
148
+ # A "--" starts a COMMENT only when followed by a space (a
149
+ # "-- text" line comment) or immediately by newline/EOF (an empty
150
+ # comment). Any other following character means the "--" itself is
151
+ # a SYM_DOUBLE_DASH token, handled by next_token instead.
152
+ def comment_ahead?
153
+ after = peek(2)
154
+ after.nil? || after == ' ' || after == "\n" || after == "\r"
155
+ end
156
+
157
+ def skip_comment
158
+ advance # first '-'
159
+ advance # second '-'
160
+ advance while !at_end? && peek != "\n"
161
+ end
162
+
163
+ def next_token(line, column)
164
+ c = peek
165
+ return read_string(line, column) if c == "'" || c == '"'
166
+ return read_parameter(line, column) if c == '$'
167
+ return read_number(line, column) if c =~ /\d/
168
+ return read_word(line, column) if c =~ /[A-Za-z]/
169
+ return read_double_dash_or_symbol(line, column) if c == '-'
170
+ return read_comparison_operator(line, column) if c =~ /[=!<>]/
171
+ return read_symbol(line, column) if SYMBOLS.key?(c)
172
+
173
+ raise ParseError.new("unexpected character #{c.inspect}", line: line, column: column)
174
+ end
175
+
176
+ def read_word(line, column)
177
+ hrid = ARCHETYPE_HRID_RE.match(remaining)&.to_s
178
+ at_id = AT_ID_CODE_RE.match(remaining)&.to_s
179
+ uri = URI_RE.match(remaining)&.to_s
180
+ plain = IDENTIFIER_RE.match(remaining)&.to_s
181
+
182
+ candidates = [
183
+ [hrid, :archetype_hrid, 0],
184
+ [at_id, :at_id_code, 1],
185
+ [uri, :uri, 2],
186
+ [plain, :plain_word, 3]
187
+ ].select { |text, _type, _pri| text }
188
+ text, kind, = candidates.max_by { |t, _k, pri| [t.length, -pri] }
189
+
190
+ text.length.times { advance }
191
+
192
+ case kind
193
+ when :archetype_hrid
194
+ Token.new(:archetype_hrid, text, line: line, column: column)
195
+ when :at_id_code
196
+ type = text.start_with?('at') ? :at_code : :id_code
197
+ Token.new(type, text, line: line, column: column)
198
+ when :uri
199
+ Token.new(:uri, text, line: line, column: column)
200
+ else
201
+ build_word_token(text, line, column)
202
+ end
203
+ end
204
+
205
+ def build_word_token(text, line, column)
206
+ downcased = text.downcase
207
+ if downcased == 'true' || downcased == 'false'
208
+ Token.new(:boolean, downcased == 'true', line: line, column: column)
209
+ elsif KEYWORDS.key?(downcased)
210
+ Token.new(KEYWORDS[downcased], text, line: line, column: column)
211
+ else
212
+ Token.new(:identifier, text, line: line, column: column)
213
+ end
214
+ end
215
+
216
+ def read_number(line, column)
217
+ text = NUMBER_RE.match(remaining).to_s
218
+ text.length.times { advance }
219
+ real = text.include?('.')
220
+ sci = text =~ /[eE]/
221
+ type = if real && sci
222
+ :sci_real
223
+ elsif real
224
+ :real
225
+ elsif sci
226
+ :sci_integer
227
+ else
228
+ :integer
229
+ end
230
+ value = real || sci ? Float(text) : Integer(text)
231
+ Token.new(type, value, line: line, column: column)
232
+ end
233
+
234
+ def read_string(line, column)
235
+ quote = advance
236
+ buffer = +''
237
+ loop do
238
+ raise ParseError.new('unterminated string literal', line: line, column: column) if at_end?
239
+
240
+ ch = advance
241
+ if ch == quote
242
+ break
243
+ elsif ch == '\\'
244
+ raise ParseError.new('unterminated string literal', line: line, column: column) if at_end?
245
+
246
+ esc = advance
247
+ buffer << (ESCAPES[esc] || esc)
248
+ else
249
+ buffer << ch
250
+ end
251
+ end
252
+ Token.new(:string, buffer, line: line, column: column)
253
+ end
254
+
255
+ def read_parameter(line, column)
256
+ advance # '$'
257
+ name = IDENTIFIER_RE.match(remaining)&.to_s
258
+ if name.nil? || name.empty?
259
+ raise ParseError.new('expected an identifier after $', line: line, column: column)
260
+ end
261
+
262
+ name.length.times { advance }
263
+ Token.new(:parameter, name, line: line, column: column)
264
+ end
265
+
266
+ def read_double_dash_or_symbol(line, column)
267
+ if peek(1) == '-'
268
+ advance
269
+ advance
270
+ Token.new(:double_dash, '--', line: line, column: column)
271
+ else
272
+ advance
273
+ Token.new(:minus, '-', line: line, column: column)
274
+ end
275
+ end
276
+
277
+ def read_comparison_operator(line, column)
278
+ c = advance
279
+ if (c == '!' || c == '<' || c == '>') && peek == '='
280
+ advance
281
+ Token.new(:comparison_operator, "#{c}=", line: line, column: column)
282
+ elsif c == '!'
283
+ raise ParseError.new("unexpected character \"!\"", line: line, column: column)
284
+ else
285
+ Token.new(:comparison_operator, c, line: line, column: column)
286
+ end
287
+ end
288
+
289
+ def read_symbol(line, column)
290
+ c = advance
291
+ Token.new(SYMBOLS.fetch(c), c, line: line, column: column)
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,45 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # containsExpr's "classExprOperand (NOT? CONTAINS containsExpr)?"
5
+ # shape: a right-recursive chain (A CONTAINS B CONTAINS C ...), so
6
+ # `child` is itself a ClassExpression, another Containment, or (from
7
+ # M7) a ContainmentAnd/ContainmentOr grouping of sibling branches.
8
+ class Containment
9
+ attr_reader :parent, :child, :negated
10
+ alias negated? negated
11
+
12
+ def initialize(parent:, child:, negated: false)
13
+ @parent = parent
14
+ @child = child
15
+ @negated = negated
16
+ freeze
17
+ end
18
+ end
19
+
20
+ # containsExpr : ... | containsExpr AND containsExpr | ... ;
21
+ # Groups sibling containment branches that must ALL be present
22
+ # under the same parent, e.g. "CONTAINS (OBSERVATION o1 AND OBSERVATION o2)".
23
+ class ContainmentAnd
24
+ attr_reader :left, :right
25
+
26
+ def initialize(left:, right:)
27
+ @left = left
28
+ @right = right
29
+ freeze
30
+ end
31
+ end
32
+
33
+ # containsExpr : ... | containsExpr OR containsExpr | ... ;
34
+ class ContainmentOr
35
+ attr_reader :left, :right
36
+
37
+ def initialize(left:, right:)
38
+ @left = left
39
+ @right = right
40
+ freeze
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,35 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # fromClause : FROM fromExpr ; fromExpr : containsExpr ;
5
+ # `containment` holds the root of the containsExpr tree. Until M3/M7
6
+ # add archetype/node predicates and CONTAINS nesting, it is always a
7
+ # single, unpredicated ClassExpression.
8
+ class FromClause
9
+ attr_reader :containment
10
+
11
+ def initialize(containment:)
12
+ @containment = containment
13
+ freeze
14
+ end
15
+ end
16
+
17
+ # classExprOperand's #classExpression alternative:
18
+ # IDENTIFIER variable=IDENTIFIER? pathPredicate?
19
+ # A bare RM class name (e.g. "COMPOSITION"), optionally bound to a
20
+ # variable and constrained by a predicate. Until M3 adds
21
+ # archetypePredicate/nodePredicate, `predicate` can only be a
22
+ # StandardPredicate (or nil).
23
+ class ClassExpression
24
+ attr_reader :class_name, :variable, :predicate
25
+
26
+ def initialize(class_name:, variable: nil, predicate: nil)
27
+ @class_name = class_name
28
+ @variable = variable
29
+ @predicate = predicate
30
+ freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # aggregateFunctionCall : name=COUNT SYM_LEFT_PAREN (DISTINCT? identifiedPath | SYM_ASTERISK) SYM_RIGHT_PAREN
5
+ # | name=(MIN | MAX | SUM | AVG) SYM_LEFT_PAREN identifiedPath SYM_RIGHT_PAREN ;
6
+ # `path` is nil only for the COUNT(*) form; `distinct` is only ever
7
+ # true for COUNT (the grammar doesn't allow DISTINCT on MIN/MAX/SUM/AVG).
8
+ class AggregateFunctionCall
9
+ attr_reader :name, :path, :distinct
10
+
11
+ def initialize(name:, path: nil, distinct: false)
12
+ @name = name
13
+ @path = path
14
+ @distinct = distinct
15
+ freeze
16
+ end
17
+ end
18
+
19
+ # functionCall : terminologyFunction
20
+ # | name=(STRING_FUNCTION_ID | NUMERIC_FUNCTION_ID | DATE_TIME_FUNCTION_ID | IDENTIFIER)
21
+ # SYM_LEFT_PAREN (terminal (SYM_COMMA terminal)*)? SYM_RIGHT_PAREN ;
22
+ class FunctionCall
23
+ attr_reader :name, :arguments
24
+
25
+ def initialize(name:, arguments: [])
26
+ @name = name
27
+ @arguments = arguments.freeze
28
+ freeze
29
+ end
30
+ end
31
+
32
+ # terminologyFunction : TERMINOLOGY SYM_LEFT_PAREN STRING SYM_COMMA STRING SYM_COMMA STRING SYM_RIGHT_PAREN ;
33
+ # The grammar doesn't name the three STRING arguments (their meaning
34
+ # - expand-operation, terminology id, value-set expression - is
35
+ # spec prose, not grammar), so they're kept as a plain positional list.
36
+ class TerminologyFunctionCall
37
+ attr_reader :args
38
+
39
+ def initialize(args:)
40
+ @args = args.freeze
41
+ freeze
42
+ end
43
+ end
44
+
45
+ # matchesOperand's bare-URI alternative: SYM_LEFT_CURLY URI SYM_RIGHT_CURLY ;
46
+ class UriRef
47
+ attr_reader :uri
48
+
49
+ def initialize(uri:)
50
+ @uri = uri
51
+ freeze
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # identifiedPath : IDENTIFIER pathPredicate? (SYM_SLASH objectPath)? ;
5
+ # A variable reference, optionally constrained by a predicate on the
6
+ # variable itself (e.g. "c[at001 or at002]"), optionally followed by
7
+ # an object path rooted at that variable (e.g. "o/data[at0001]/.../value").
8
+ class IdentifiedPath
9
+ attr_reader :variable, :predicate, :path
10
+
11
+ def initialize(variable:, predicate: nil, path: nil)
12
+ @variable = variable
13
+ @predicate = predicate
14
+ @path = path
15
+ freeze
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # primitive : STRING | numericPrimitive | DATE | TIME | DATETIME | BOOLEAN | NULL ;
5
+ class Literal
6
+ attr_reader :value
7
+
8
+ def initialize(value:)
9
+ @value = value
10
+ freeze
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,34 @@
1
+ module OpenEHR
2
+ module AQL
3
+ module Model
4
+ # objectPath : pathPart (SYM_SLASH pathPart)* ;
5
+ class ObjectPath
6
+ attr_reader :segments
7
+
8
+ def initialize(segments:)
9
+ @segments = segments.freeze
10
+ freeze
11
+ end
12
+
13
+ def to_s
14
+ segments.map(&:to_s).join('/')
15
+ end
16
+ end
17
+
18
+ # pathPart : IDENTIFIER pathPredicate? ;
19
+ class PathPart
20
+ attr_reader :attribute, :predicate
21
+
22
+ def initialize(attribute:, predicate: nil)
23
+ @attribute = attribute
24
+ @predicate = predicate
25
+ freeze
26
+ end
27
+
28
+ def to_s
29
+ predicate ? "#{attribute}[#{predicate}]" : attribute
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end