openehr 2.1.0 → 2.3.1

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.
@@ -0,0 +1,255 @@
1
+ require 'nokogiri'
2
+ require_relative 'xml_constraint_parsing'
3
+ require_relative 'xml_primitive_parsing'
4
+ require_relative 'xml_domain_type_parsing'
5
+
6
+ module OpenEHR
7
+ module Parser
8
+ # Reads the canonical openEHR ITS-XML archetype shape that
9
+ # XMLSerializer (lib/openehr/serializer/xml_serializer.rb) emits -
10
+ # see that file's header comment for how that shape was established
11
+ # as ground truth. Mirrors ADLParser#archetype's construction
12
+ # pattern (a small constructor-argument method per Archetype.new
13
+ # keyword) and shares the same <definition> constraint-tree readers
14
+ # as OPTParser via the 3 modules extracted in B3
15
+ # (xml_constraint_parsing/xml_primitive_parsing/xml_domain_type_parsing).
16
+ class XMLArchetypeParser < ::OpenEHR::Parser::Base
17
+ include XMLConstraintParsing
18
+ include XMLPrimitiveParsing
19
+ include XMLDomainTypeParsing
20
+
21
+ def parse
22
+ archetype
23
+ rescue OpenEHR::Parser::ParseError
24
+ raise
25
+ rescue StandardError => e
26
+ raise OpenEHR::Parser::ParseError, "invalid XML archetype (#{@filename}): #{e.class}: #{e.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def doc
32
+ @doc ||= begin
33
+ parsed = Nokogiri::XML::Document.parse(File.open(@filename, 'rb:bom|utf-8'))
34
+ parsed.remove_namespaces!
35
+ parsed
36
+ end
37
+ end
38
+
39
+ def root
40
+ @root ||= doc.at('archetype')
41
+ end
42
+
43
+ def text_on_path(xml, path)
44
+ node = xml.at(path)
45
+ node.nil? ? nil : node.text
46
+ end
47
+
48
+ def code_phrase_from(node)
49
+ return nil if node.nil?
50
+
51
+ terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: text_on_path(node, 'terminology_id/value'))
52
+ OpenEHR::RM::DataTypes::Text::CodePhrase.new(terminology_id: terminology_id, code_string: text_on_path(node, 'code_string'))
53
+ end
54
+
55
+ # Nodes emitted with an 'id' attribute keying a plain text value
56
+ # (original_author/other_details/author/original_resource_uri) -
57
+ # the same shape everywhere it's used, so read generically.
58
+ def id_keyed_hash(nodes)
59
+ return nil if nodes.empty?
60
+
61
+ nodes.each_with_object({}) { |n, hash| hash[n['id']] = n.text }
62
+ end
63
+
64
+ def archetype_id
65
+ OpenEHR::RM::Support::Identification::ArchetypeID.new(value: text_on_path(root, 'archetype_id/value'))
66
+ end
67
+
68
+ def uid
69
+ value = text_on_path(root, 'uid/value')
70
+ value.nil? ? nil : OpenEHR::RM::Support::Identification::HierObjectID.new(value: value)
71
+ end
72
+
73
+ def adl_version
74
+ text_on_path(root, 'adl_version')
75
+ end
76
+
77
+ def parent_archetype_id
78
+ value = text_on_path(root, 'parent_archetype_id/value')
79
+ value.nil? ? nil : OpenEHR::RM::Support::Identification::ArchetypeID.new(value: value)
80
+ end
81
+
82
+ def concept
83
+ text_on_path(root, 'concept')
84
+ end
85
+
86
+ def original_language
87
+ code_phrase_from(root.at('original_language'))
88
+ end
89
+
90
+ def translations
91
+ translations_node = root.at('translations')
92
+ return nil if translations_node.nil?
93
+
94
+ translations_node.xpath('translation').each_with_object({}) do |node, hash|
95
+ hash[node['language']] = translation_details(node)
96
+ end
97
+ end
98
+
99
+ def translation_details(node)
100
+ OpenEHR::RM::Common::Resource::TranslationDetails.new(
101
+ language: code_phrase_from(node.at('language')),
102
+ author: id_keyed_hash(node.xpath('author')),
103
+ accreditation: text_on_path(node, 'accreditation'),
104
+ other_details: id_keyed_hash(node.xpath('other_details'))
105
+ )
106
+ end
107
+
108
+ def description
109
+ node = root.at('description')
110
+ return nil if node.nil?
111
+
112
+ OpenEHR::RM::Common::Resource::ResourceDescription.new(
113
+ original_author: id_keyed_hash(node.xpath('original_author')),
114
+ other_contributors: description_other_contributors(node),
115
+ lifecycle_state: text_on_path(node, 'lifecycle_state'),
116
+ details: description_details(node)
117
+ )
118
+ end
119
+
120
+ def description_other_contributors(node)
121
+ contributors = node.xpath('other_contributors').map(&:text)
122
+ contributors.empty? ? nil : contributors
123
+ end
124
+
125
+ def description_details(node)
126
+ node.xpath('details/detail').each_with_object({}) do |detail, hash|
127
+ hash[detail['language']] = description_detail_item(detail)
128
+ end
129
+ end
130
+
131
+ def description_detail_item(node)
132
+ keywords = node.xpath('keywords').map(&:text)
133
+ OpenEHR::RM::Common::Resource::ResourceDescriptionItem.new(
134
+ language: code_phrase_from(node.at('language')),
135
+ purpose: text_on_path(node, 'purpose'),
136
+ keywords: keywords.empty? ? nil : keywords,
137
+ use: text_on_path(node, 'use'),
138
+ misuse: text_on_path(node, 'misuse'),
139
+ copyright: text_on_path(node, 'copyright'),
140
+ original_resource_uri: id_keyed_hash(node.xpath('original_resource_uri')),
141
+ other_details: id_keyed_hash(node.xpath('other_details'))
142
+ )
143
+ end
144
+
145
+ # Unlike OPTParser (whose top-level definition is a
146
+ # CArchetypeRoot, read via c_archetype_root, which has its own
147
+ # root-path override), a standalone archetype's <definition> root
148
+ # is a plain CComplexObject. c_complex_object (shared with
149
+ # OPTParser) always appends [node_id] to the path it's handed,
150
+ # which is correct for every *nested* complex object it's called
151
+ # on via attributes()/children() but wrong for the root itself
152
+ # (whose path must stay "/") - so the root is built directly here
153
+ # instead, mirroring c_archetype_root's override.
154
+ def definition
155
+ xml = root.at('definition')
156
+ node = Node.new
157
+ rm_type_name = text_on_path(xml, './rm_type_name')
158
+ node_id = text_on_path(xml, './node_id')
159
+ node.id = node_id unless node_id.nil? || node_id.empty?
160
+ node.path = '/'
161
+ OpenEHR::AM::Archetype::ConstraintModel::CComplexObject.new(
162
+ rm_type_name: rm_type_name, node_id: node.id, path: node.path,
163
+ occurrences: occurrences(xml.xpath('./occurrences')), attributes: attributes(xml.xpath('./attributes'), node)
164
+ )
165
+ end
166
+
167
+ def invariants
168
+ node = root.at('invariants')
169
+ return nil if node.nil?
170
+
171
+ node.xpath('invariant').map { |invariant| invariant_assertion(invariant) }
172
+ end
173
+
174
+ def invariant_assertion(node)
175
+ assertions(node, Node.new).first
176
+ end
177
+
178
+ def ontology
179
+ node = root.at('ontology')
180
+ OpenEHR::AM::Archetype::Ontology::ArchetypeOntology.new(
181
+ primary_language: text_on_path(node, 'primary_language'),
182
+ specialisation_depth: ontology_specialisation_depth(node),
183
+ languages_available: ontology_string_list(node, 'languages_available'),
184
+ terminologies_available: ontology_string_list(node, 'terminologies_available'),
185
+ term_definitions: ontology_term_definitions(node, 'term_definitions'),
186
+ constraint_definitions: ontology_optional_term_definitions(node, 'constraint_definitions'),
187
+ term_bindings: ontology_bindings(node, 'term_bindings') { |value| [code_phrase_from_binding(value)] },
188
+ constraint_bindings: ontology_bindings(node, 'constraint_bindings') { |value| OpenEHR::RM::DataTypes::URI::DvUri.new(value: value) }
189
+ )
190
+ end
191
+
192
+ def ontology_specialisation_depth(node)
193
+ value = text_on_path(node, 'specialisation_depth')
194
+ value.nil? ? nil : value.to_i
195
+ end
196
+
197
+ def ontology_string_list(node, keyword)
198
+ values = node.xpath(keyword).map(&:text)
199
+ values.empty? ? nil : values
200
+ end
201
+
202
+ def ontology_term_definitions(node, keyword)
203
+ node.xpath(keyword).each_with_object({}) do |term_node, by_lang|
204
+ lang = term_node['language']
205
+ (by_lang[lang] ||= {})[term_node['code']] = archetype_term(term_node)
206
+ end
207
+ end
208
+
209
+ def ontology_optional_term_definitions(node, keyword)
210
+ result = ontology_term_definitions(node, keyword)
211
+ result.empty? ? nil : result
212
+ end
213
+
214
+ def archetype_term(term_node)
215
+ items = term_node.xpath('items').each_with_object({}) { |item, hash| hash[item['id']] = item.text }
216
+ OpenEHR::AM::Archetype::Ontology::ArchetypeTerm.new(code: term_node['code'], items: items)
217
+ end
218
+
219
+ def ontology_bindings(node, keyword)
220
+ result = node.xpath(keyword).each_with_object({}) do |binding_node, by_terminology|
221
+ terminology = binding_node['terminology']
222
+ (by_terminology[terminology] ||= {})[binding_node['code']] = yield(binding_node.text)
223
+ end
224
+ result.empty? ? nil : result
225
+ end
226
+
227
+ # term_bindings' value is a "terminology::code" qualified
228
+ # reference (matching ADLSerializer/XMLSerializer's own emission),
229
+ # not a bare code - split it back into a CodePhrase.
230
+ def code_phrase_from_binding(value)
231
+ terminology, code = value.split('::', 2)
232
+ OpenEHR::RM::DataTypes::Text::CodePhrase.new(
233
+ terminology_id: OpenEHR::RM::Support::Identification::TerminologyID.new(value: terminology),
234
+ code_string: code
235
+ )
236
+ end
237
+
238
+ def archetype
239
+ OpenEHR::AM::Archetype::Archetype.new(
240
+ archetype_id: archetype_id,
241
+ adl_version: adl_version,
242
+ uid: uid,
243
+ concept: concept,
244
+ original_language: original_language,
245
+ translations: translations,
246
+ description: description,
247
+ definition: definition,
248
+ ontology: ontology,
249
+ parent_archetype_id: parent_archetype_id,
250
+ invariants: invariants
251
+ )
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,275 @@
1
+ module OpenEHR
2
+ module Parser
3
+ # Structural/constraint-tree node builders (C_COMPLEX_OBJECT,
4
+ # C_ATTRIBUTE, C_PRIMITIVE_OBJECT, ARCHETYPE_SLOT, ARCHETYPE_INTERNAL_REF,
5
+ # CONSTRAINT_REF, ASSERTION) shared between OPTParser and
6
+ # XMLArchetypeParser - both read the same canonical shape (see
7
+ # lib/openehr/serializer/xml_serializer.rb's header comment for how
8
+ # that shape was established as ground truth against real .opt
9
+ # fixtures). Extracted from opt_parser.rb without behavior changes,
10
+ # except: expr_unary_operator support and tag reading in assertions()
11
+ # were both added here since XMLSerializer already emits them, and
12
+ # occurrences()/numeric bounds are now real-aware where the caller
13
+ # asks for it (C_REAL ranges were silently truncated to Integer via
14
+ # String#to_i before this).
15
+ module XMLConstraintParsing
16
+ private
17
+
18
+ def c_archetype_root(xml, node = Node.new)
19
+ rm_type_name = text_on_path(xml, './rm_type_name')
20
+ id = text_on_path(xml, './node_id')
21
+ node.id = id unless id.nil? or id.empty?
22
+ occurrences = occurrences(xml.xpath('./occurrences'))
23
+ archetype_id = OpenEHR::RM::Support::Identification::ArchetypeID.new(value: text_on_path(xml, './archetype_id/value'))
24
+ if node.root? or node.id.nil?
25
+ node.path = "/"
26
+ end
27
+ component_terminologies(archetype_id, xml)
28
+ OpenEHR::AM::Archetype::ConstraintModel::CArchetypeRoot.new(rm_type_name: rm_type_name, node_id: node.id, path: node.path, occurrences: occurrences, archetype_id: archetype_id, attributes: attributes(xml.xpath('./attributes'), node))
29
+ end
30
+
31
+ def c_complex_object(xml, node)
32
+ rm_type_name = xml.xpath('./rm_type_name').text
33
+ node_id = xml.xpath('./node_id').text
34
+ unless node_id.nil? or node_id.empty?
35
+ node.id = node_id
36
+ node.path = "#{node.path}[#{node.id}]"
37
+ end
38
+ OpenEHR::AM::Archetype::ConstraintModel::CComplexObject.new(rm_type_name: rm_type_name, node_id: node.id, path: node.path, occurrences: occurrences(xml.xpath('./occurrences')), attributes: attributes(xml.xpath('./attributes'), node))
39
+ end
40
+
41
+ def attributes(attributes_xml, node)
42
+ attributes_xml.map do |attr|
43
+ rm_attribute_name = attr.at('rm_attribute_name').text
44
+ if node.root?
45
+ path = "/#{rm_attribute_name}"
46
+ else
47
+ path = "#{node.path}/#{rm_attribute_name}"
48
+ end
49
+ child_node = Node.new(node)
50
+ child_node.path = path
51
+ child_node.id = node.id
52
+ send attr.attributes['type'].text.downcase, attr, child_node
53
+ end
54
+ end
55
+
56
+ # Each sibling child gets its own Node, copied fresh from the
57
+ # attribute's node - c_complex_object mutates node.path/node.id
58
+ # in place when a node_id is present, so sharing one Node across
59
+ # a map() here would leak sibling A's path into sibling B (e.g. a
60
+ # C_MULTIPLE_ATTRIBUTE with 2+ C_COMPLEX_OBJECT children, each
61
+ # with its own node_id, would produce "/items[a][b]" instead of
62
+ # "/items[a]" and "/items[b]").
63
+ def children(children_xml, node)
64
+ children_xml.map do |child|
65
+ child_node = Node.new(node)
66
+ child_node.path = node.path
67
+ child_node.id = node.id
68
+ type_name = child.attributes['type'].text
69
+ handler = type_name.downcase
70
+ if respond_to?(handler, true)
71
+ send handler, child, child_node
72
+ else
73
+ # children is open to vendor/newer-model constraint extensions;
74
+ # the other xsi:type dispatch sites are schema-closed. Preserve
75
+ # the C_OBJECT core here, while warning that type-specific
76
+ # constraints are dropped and validation becomes more permissive.
77
+ warn "openehr parser: unknown constraint type \"#{type_name}\" at #{child_node.path}; treating as C_COMPLEX_OBJECT (type-specific constraints dropped)"
78
+ c_complex_object(child, child_node)
79
+ end
80
+ end
81
+ end
82
+
83
+ def c_single_attribute(attr_xml, node)
84
+ rm_attribute_name = attr_xml.at('rm_attribute_name').text
85
+ existence = occurrences(attr_xml.at('existence'))
86
+ OpenEHR::AM::Archetype::ConstraintModel::CSingleAttribute.new(rm_attribute_name: rm_attribute_name, existence: existence, path: node.path, children: children(attr_xml.xpath('./children'), node))
87
+ end
88
+
89
+ def c_multiple_attribute(attr_xml, node)
90
+ rm_attribute_name = attr_xml.at('rm_attribute_name').text
91
+ existence = occurrences(attr_xml.at('existence'))
92
+ OpenEHR::AM::Archetype::ConstraintModel::CMultipleAttribute.new(rm_attribute_name: rm_attribute_name, existence: existence, path: node.path, cardinality: cardinality(attr_xml), children: children(attr_xml.xpath('./children'), node))
93
+ end
94
+
95
+ def archetype_slot(attr_xml, node)
96
+ node_id = attr_xml.at('node_id').text
97
+ node.id = node_id
98
+ # Matches c_complex_object's path convention: a slot's own
99
+ # node_id belongs in its path (e.g. "/items[at0053]"), not just
100
+ # its parent attribute's path - without this, two slots under
101
+ # the same C_MULTIPLE_ATTRIBUTE would collapse to one path.
102
+ node.path = "#{node.path}[#{node.id}]" unless node_id.nil? || node_id.empty?
103
+ rm_type_name = attr_xml.at('rm_type_name').text
104
+ occurrences = occurrences(attr_xml.at('occurrences'))
105
+ includes_leaf = attr_xml.at('includes')
106
+ includes = assertions(includes_leaf.children, node) if includes_leaf
107
+ excludes_leaf = attr_xml.at('excludes')
108
+ excludes = assertions(excludes_leaf.children, node) if excludes_leaf
109
+ OpenEHR::AM::Archetype::ConstraintModel::ArchetypeSlot.new(path: node.path, node_id: node.id, rm_type_name: rm_type_name, occurrences: occurrences, includes: includes, excludes: excludes)
110
+ end
111
+
112
+ def occurrences(occurrence_xml)
113
+ numeric_interval(occurrence_xml, real: false)
114
+ end
115
+
116
+ # Shared by occurrences()/existence/cardinality (always Integer
117
+ # bounds) and C_REAL's range (Real bounds, via numeric_interval's
118
+ # real: true - see xml_primitive_parsing.rb's c_real).
119
+ def numeric_interval(occurrence_xml, real:)
120
+ return nil if occurrence_xml.nil?
121
+
122
+ lower_node = occurrence_xml.at('lower')
123
+ upper_node = occurrence_xml.at('upper')
124
+ lower_included_node = occurrence_xml.at('lower_included')
125
+ upper_included_node = occurrence_xml.at('upper_included')
126
+ lower_unbounded_node = occurrence_xml.at('lower_unbounded')
127
+ upper_unbounded_node = occurrence_xml.at('upper_unbounded')
128
+
129
+ lower = lower_node ? numeric_bound(lower_node.text, real) : nil
130
+ upper = upper_node ? numeric_bound(upper_node.text, real) : nil
131
+ lower_included = lower_included_node ? to_bool(lower_included_node.text) : (lower.nil? ? nil : true)
132
+ upper_included = upper_included_node ? to_bool(upper_included_node.text) : (upper.nil? ? nil : true)
133
+ lower_unbounded = lower_unbounded_node ? to_bool(lower_unbounded_node.text) : false
134
+ upper_unbounded = upper_unbounded_node ? to_bool(upper_unbounded_node.text) : false
135
+
136
+ # An occurrences element with none of its children present carries no
137
+ # constraint at all; Interval requires at least one bound, so treat
138
+ # this as "no occurrences data" rather than raising.
139
+ return nil if lower.nil? && upper.nil? && !lower_unbounded && !upper_unbounded
140
+
141
+ # Handle unbounded intervals properly
142
+ if upper_unbounded || upper.nil?
143
+ upper = nil
144
+ upper_included = nil
145
+ end
146
+
147
+ if lower_unbounded || lower.nil?
148
+ lower = nil
149
+ lower_included = nil
150
+ end
151
+
152
+ OpenEHR::AssumedLibraryTypes::Interval.new(
153
+ lower: lower,
154
+ upper: upper,
155
+ lower_included: lower_included,
156
+ upper_included: upper_included
157
+ )
158
+ end
159
+
160
+ def numeric_bound(text, real)
161
+ real ? text.to_f : text.to_i
162
+ end
163
+
164
+ def cardinality(xml)
165
+ return nil if xml.nil?
166
+
167
+ order_node = xml.at('is_ordered')
168
+ unique_node = xml.at('is_unique')
169
+ interval_node = xml.at('interval')
170
+
171
+ # No cardinality sub-elements at all means no cardinality data.
172
+ return nil if order_node.nil? && unique_node.nil? && interval_node.nil?
173
+
174
+ order = order_node ? to_bool(order_node.text) : false
175
+ unique = unique_node ? to_bool(unique_node.text) : false
176
+ interval = interval_node ? occurrences(interval_node) : nil
177
+
178
+ OpenEHR::AM::Archetype::ConstraintModel::Cardinality.new(
179
+ is_ordered: order,
180
+ is_unique: unique,
181
+ interval: interval
182
+ )
183
+ end
184
+
185
+ def archetype_internal_ref(attr_xml, node)
186
+ rm_type_name = attr_xml.at('rm_type_name').text
187
+ target_path = attr_xml.at('target_path').text
188
+ occurrences = occurrences(attr_xml.at('occurrences'))
189
+ OpenEHR::AM::Archetype::ConstraintModel::ArchetypeInternalRef.new(rm_type_name: rm_type_name, occurrences: occurrences, target_path: target_path)
190
+ end
191
+
192
+ def constraint_ref(attr_xml, node)
193
+ rm_type_name = attr_xml.at('rm_type_name').text
194
+ reference = attr_xml.at('reference').text
195
+ occurrences = occurrences(attr_xml.at('occurrences'))
196
+ OpenEHR::AM::Archetype::ConstraintModel::ConstraintRef.new(rm_type_name: rm_type_name, occurrences: occurrences, reference: reference)
197
+ end
198
+
199
+ def assertions(attr_xml, node)
200
+ tag_node = attr_xml.at('tag')
201
+ tag = tag_node.nil? ? nil : tag_node.text
202
+ string_expression = attr_xml.at('string_expression')
203
+ string_expression = string_expression.nil? ? nil : string_expression.text
204
+ expression_leaf = attr_xml.at 'expression'
205
+ expression = send expression_leaf.attributes['type'].text.downcase, expression_leaf
206
+ [OpenEHR::AM::Archetype::Assertion::Assertion.new(tag: tag, expression: expression, string_expression: string_expression)]
207
+ end
208
+
209
+ def expr_binary_operator(attr_xml)
210
+ type = attr_xml.at('type').text
211
+ operator = OpenEHR::AM::Archetype::Assertion::OperatorKind.new(value: attr_xml.at('operator').text.to_i)
212
+
213
+ precedence_overridden = attr_xml.at('precedence_overridden').text == 'true' ? true : false
214
+ right_operand_leaf = attr_xml.at 'right_operand'
215
+ right_operand = send right_operand_leaf.attributes['type'].text.downcase, right_operand_leaf
216
+ left_operand_leaf = attr_xml.at 'left_operand'
217
+ left_operand = send left_operand_leaf.attributes['type'].text.downcase, left_operand_leaf
218
+ OpenEHR::AM::Archetype::Assertion::ExprBinaryOperator.new(type: type, operator: operator, precedence_overridden: precedence_overridden, right_operand: right_operand, left_operand: left_operand)
219
+ end
220
+
221
+ def expr_unary_operator(attr_xml)
222
+ type = attr_xml.at('type').text
223
+ operator = OpenEHR::AM::Archetype::Assertion::OperatorKind.new(value: attr_xml.at('operator').text.to_i)
224
+ precedence_overridden = attr_xml.at('precedence_overridden').text == 'true' ? true : false
225
+ operand_leaf = attr_xml.at 'operand'
226
+ operand = send operand_leaf.attributes['type'].text.downcase, operand_leaf
227
+ OpenEHR::AM::Archetype::Assertion::ExprUnaryOperator.new(type: type, operator: operator, precedence_overridden: precedence_overridden, operand: operand)
228
+ end
229
+
230
+ def expr_leaf(attr_xml)
231
+ type = attr_xml.at('type').text
232
+ item_leaf = attr_xml.at('item')
233
+ item = send type.downcase, item_leaf
234
+ reference_type = attr_xml.at('reference_type').text
235
+ OpenEHR::AM::Archetype::Assertion::ExprLeaf.new(type: type, item: item, reference_type: reference_type)
236
+ end
237
+
238
+ def c_primitive_object(attr_xml, node)
239
+ rm_type_name = attr_xml.at('rm_type_name').text
240
+ occurrences = occurrences(attr_xml.at('occurrences'))
241
+ item = send attr_xml.at('item')['type'].downcase, attr_xml.at('item')
242
+ OpenEHR::AM::Archetype::ConstraintModel::CPrimitiveObject.new(rm_type_name: rm_type_name, occurrences: occurrences, node_id: node.id, item: item)
243
+ end
244
+
245
+ # Bare-literal ExprLeaf#item readers (type "String"/"Integer"/
246
+ # "Real"/"Boolean", reference_type "CONSTANT") - distinct from
247
+ # the C_STRING/C_INTEGER/... constraint-item readers in
248
+ # xml_primitive_parsing.rb, which are dispatched to for
249
+ # reference_type "constraint" leaves instead.
250
+ def string(attr_xml)
251
+ attr_xml.text
252
+ end
253
+
254
+ def integer(attr_xml)
255
+ attr_xml.text.to_i
256
+ end
257
+
258
+ def real(attr_xml)
259
+ attr_xml.text.to_f
260
+ end
261
+
262
+ def boolean(attr_xml)
263
+ to_bool(attr_xml.text)
264
+ end
265
+
266
+ def to_bool(str)
267
+ return nil if str.nil?
268
+ str = str.text if str.respond_to?(:text)
269
+ return true if /true/i =~ str.to_s
270
+ return false if /false/i =~ str.to_s
271
+ nil
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,142 @@
1
+ module OpenEHR
2
+ module Parser
3
+ # openEHR Archetype Profile domain-type readers (C_CODE_PHRASE,
4
+ # C_DV_QUANTITY, C_DV_ORDINAL, C_DV_SCALE, C_DV_STATE), shared
5
+ # between OPTParser and XMLArchetypeParser. Extracted from
6
+ # opt_parser.rb without behavior changes, except: C_DV_QUANTITY now
7
+ # reads assumed_value (previously ignored) and its magnitude range
8
+ # reads Float bounds instead of Integer (precision stays Integer -
9
+ # see numeric_interval in xml_constraint_parsing.rb).
10
+ module XMLDomainTypeParsing
11
+ private
12
+
13
+ def c_code_phrase(attr_xml, node)
14
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Text::CCodePhrase.new(
15
+ code_phrase_constraint_args(attr_xml, node)
16
+ )
17
+ end
18
+
19
+ def c_code_reference(attr_xml, node)
20
+ args = code_phrase_constraint_args(attr_xml, node)
21
+ args[:code_list] = nil if args[:code_list] && args[:code_list].empty?
22
+ uri = attr_xml.at('referenceSetUri')&.text&.strip
23
+ args[:reference_set_uri] = uri unless uri.nil? || uri.empty?
24
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Text::CCodeReference.new(args)
25
+ end
26
+
27
+ def code_phrase_constraint_args(attr_xml, node)
28
+ terminology_id_node = attr_xml.at('terminology_id/value')
29
+ terminology_id = terminology_id_node ? OpenEHR::RM::Support::Identification::TerminologyID.new(value: terminology_id_node.text.strip) : nil
30
+
31
+ code_list_nodes = attr_xml.xpath('code_list')
32
+ code_list = code_list_nodes.map { |code_node| code_node.text.strip }
33
+ code_list = [code_list.first] if code_list.size == 1 && code_list.first.empty?
34
+
35
+ occurrences_node = attr_xml.at('occurrences')
36
+ occurrences_obj = occurrences_node ? occurrences(occurrences_node) : nil
37
+
38
+ {
39
+ terminology_id: terminology_id,
40
+ code_list: code_list,
41
+ path: node.path,
42
+ occurrences: occurrences_obj,
43
+ rm_type_name: 'CODE_PHRASE'
44
+ }
45
+ end
46
+
47
+ # The <property> element is optional in real templates; return nil rather
48
+ # than dereferencing missing terminology/code nodes.
49
+ def property_code_phrase(property_xml)
50
+ return nil if property_xml.nil?
51
+ terminology_node = property_xml.at('terminology_id/value')
52
+ code_node = property_xml.at('code_string')
53
+ return nil if terminology_node.nil? || code_node.nil?
54
+ terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: terminology_node.text)
55
+ OpenEHR::RM::DataTypes::Text::CodePhrase.new(terminology_id: terminology_id, code_string: code_node.text)
56
+ end
57
+
58
+ def c_dv_quantity(attr_xml, node)
59
+ rm_type_name = attr_xml.at('rm_type_name').text
60
+ occurrences = occurrences(attr_xml.at('occurrences'))
61
+ property = property_code_phrase(attr_xml.at('property'))
62
+ list = attr_xml.xpath('.//list').map { |element| c_quantity_item(element) }
63
+ assumed_value = dv_quantity_assumed_value(attr_xml.at('assumed_value'))
64
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvQuantity.new(
65
+ rm_type_name: rm_type_name, occurrences: occurrences, list: list, property: property, assumed_value: assumed_value
66
+ )
67
+ end
68
+
69
+ def c_quantity_item(element)
70
+ units = element.at('units').text if element.at('units')
71
+ magnitude = numeric_interval(element.at('magnitude'), real: true) if element.at('magnitude')
72
+ precision = numeric_interval(element.at('precision'), real: false) if element.at('precision')
73
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CQuantityItem.new(magnitude: magnitude, precision: precision, units: units)
74
+ end
75
+
76
+ # assumed_value is a real DV_QUANTITY (plain magnitude/precision,
77
+ # not a range), matching XMLSerializer#emit_dv_quantity_assumed_value.
78
+ def dv_quantity_assumed_value(element)
79
+ return nil if element.nil?
80
+
81
+ units = element.at('units')&.text
82
+ magnitude_node = element.at('magnitude')
83
+ magnitude = magnitude_node ? magnitude_node.text.to_f : nil
84
+ precision_node = element.at('precision')
85
+ precision = precision_node ? precision_node.text.to_i : nil
86
+ OpenEHR::RM::DataTypes::Quantity::DvQuantity.new(units: units, magnitude: magnitude, precision: precision)
87
+ end
88
+
89
+ def c_dv_ordinal(attr_xml, node)
90
+ rm_type_name = attr_xml.at('rm_type_name').text
91
+ occurrences = occurrences(attr_xml.at('occurrences'))
92
+ list = attr_xml.xpath('list').map { |element| dv_ordinal_item(element) }.compact
93
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvOrdinal.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list)
94
+ end
95
+
96
+ # DV_ORDINAL.symbol is spec'd as DV_CODED_TEXT; the OPT XML only
97
+ # carries a defining_code (terminology_id + code_string), so the
98
+ # DvCodedText's own value is set to that same code_string (there
99
+ # is no separate display text in this element).
100
+ def dv_ordinal_item(element)
101
+ value_node = element.at('value')
102
+ return nil unless value_node && !value_node.text.empty?
103
+
104
+ code_phrase = property_code_phrase(element.at('symbol/defining_code'))
105
+ return nil if code_phrase.nil?
106
+
107
+ symbol = OpenEHR::RM::DataTypes::Text::DvCodedText.new(value: code_phrase.code_string, defining_code: code_phrase)
108
+ OpenEHR::RM::DataTypes::Quantity::DvOrdinal.new(value: value_node.text.to_i, symbol: symbol)
109
+ end
110
+
111
+ def c_dv_scale(attr_xml, node)
112
+ rm_type_name = attr_xml.at('rm_type_name').text
113
+ occurrences = occurrences(attr_xml.at('occurrences'))
114
+ list = attr_xml.xpath('list').map { |element| dv_scale_item(element) }.compact
115
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvScale.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list)
116
+ end
117
+
118
+ # Same XML shape as C_DV_ORDINAL's list items, but DV_SCALE.value
119
+ # is Real rather than Integer.
120
+ def dv_scale_item(element)
121
+ value_node = element.at('value')
122
+ return nil unless value_node && !value_node.text.empty?
123
+
124
+ code_phrase = property_code_phrase(element.at('symbol/defining_code'))
125
+ return nil if code_phrase.nil?
126
+
127
+ symbol = OpenEHR::RM::DataTypes::Text::DvCodedText.new(value: code_phrase.code_string, defining_code: code_phrase)
128
+ OpenEHR::RM::DataTypes::Quantity::DvScale.new(value: value_node.text.to_f, symbol: symbol)
129
+ end
130
+
131
+ # No .opt fixture in this gem's corpus uses a C_DV_STATE (state
132
+ # machine) constraint, so its actual OPT XML shape is unverified,
133
+ # and XMLSerializer itself can't emit one to a standalone
134
+ # archetype either (ADL 1.4 has no grammar rule for it) - raising
135
+ # a clear, documented error here is safer than guessing at
136
+ # element names and risking a silently wrong StateMachine.
137
+ def c_dv_state(_attr_xml, _node)
138
+ raise NotImplementedError, 'OPTParser does not yet support C_DV_STATE (state machine) constraints'
139
+ end
140
+ end
141
+ end
142
+ end