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
|
@@ -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
|