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
@@ -39,15 +39,42 @@ module OpenEHR
39
39
  def parse
40
40
  @opt = Nokogiri::XML::Document.parse(File.open(@filename))
41
41
  @opt.remove_namespaces!
42
- uid = OpenEHR::RM::Support::Identification::UIDBasedID.new(value: text_on_path(@opt, UID_PATH))
42
+
43
+ uid = build_uid
43
44
  defs = definition
44
- OpenEHR::AM::Template::OperationalTemplate.new(uid: uid, concept: concept, language: language, description: description, template_id: template_id, definition: defs, component_terminologies: @component_terminologies)
45
+
46
+ # Create operational template with archetype-compatible parameters
47
+ OpenEHR::AM::Template::OperationalTemplate.new(
48
+ uid: uid,
49
+ concept: concept,
50
+ original_language: language,
51
+ description: description,
52
+ template_id: template_id,
53
+ archetype_id: template_id, # Use template_id as archetype_id for compatibility
54
+ definition: defs,
55
+ ontology: (@component_terminologies || {})[defs.archetype_id.value] || create_template_ontology,
56
+ component_terminologies: @component_terminologies || {},
57
+ terminology_extracts: @component_terminologies || {},
58
+ adl_version: "1.4"
59
+ )
45
60
  end
46
61
 
47
62
  private
48
63
 
64
+ # template_id is mandatory for an operational template (enforced by
65
+ # OperationalTemplate itself); a missing/blank <template_id> element
66
+ # must therefore resolve to nil rather than an invalid TemplateID.
49
67
  def template_id
50
- @template_id ||= OpenEHR::RM::Support::Identification::TemplateID.new(value: text_on_path(@opt, TEMPLATE_ID_PATH))
68
+ return @template_id if @template_id
69
+ value = text_on_path(@opt, TEMPLATE_ID_PATH)
70
+ @template_id = value.nil? || value.empty? ? nil : OpenEHR::RM::Support::Identification::TemplateID.new(value: value)
71
+ end
72
+
73
+ # uid is optional on an operational template; a missing/blank <uid>
74
+ # element must resolve to nil rather than an invalid UIDBasedID.
75
+ def build_uid
76
+ value = text_on_path(@opt, UID_PATH)
77
+ value.nil? || value.empty? ? nil : OpenEHR::RM::Support::Identification::UIDBasedID.new(value: value)
51
78
  end
52
79
 
53
80
  def concept
@@ -92,6 +119,30 @@ module OpenEHR
92
119
  archetype_terminology(nodes)
93
120
  end
94
121
 
122
+ def create_template_ontology
123
+ # Create a basic ontology for the template using the main concept
124
+ concept_code = 'at0000'
125
+ original_lang = language
126
+
127
+ term_definitions = {
128
+ original_lang.code_string => [
129
+ OpenEHR::AM::Archetype::Terminology::ArchetypeTerm.new(
130
+ code: concept_code,
131
+ items: {
132
+ 'text' => concept || 'Template',
133
+ 'description' => 'Operational template'
134
+ }
135
+ )
136
+ ]
137
+ }
138
+
139
+ OpenEHR::AM::Archetype::Terminology::ArchetypeTerminology.new(
140
+ concept_code: concept_code,
141
+ original_language: original_lang,
142
+ term_definitions: term_definitions
143
+ )
144
+ end
145
+
95
146
  def archetype_terminology(nodes)
96
147
  td = term_definitions(nodes)
97
148
  concept_code = td[language.code_string][0]
@@ -174,10 +225,23 @@ module OpenEHR
174
225
  end
175
226
 
176
227
  def c_code_phrase(attr_xml, node)
177
- terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: attr_xml.at('terminology_id/value').text.strip)
178
- code_list = attr_xml.xpath('code_list').text.strip
179
- occurrences = occurrences(attr_xml.at('occurrences'))
180
- OpenEHR::AM::OpenEHRProfile::DataTypes::Text::CCodePhrase.new(terminology_id: terminology_id, code_list: [code_list], path: node.path, occurrences: occurrences, rm_type_name: 'CodePhrase')
228
+ terminology_id_node = attr_xml.at('terminology_id/value')
229
+ terminology_id = terminology_id_node ? OpenEHR::RM::Support::Identification::TerminologyID.new(value: terminology_id_node.text.strip) : nil
230
+
231
+ code_list_nodes = attr_xml.xpath('code_list')
232
+ code_list = code_list_nodes.map { |code_node| code_node.text.strip }
233
+ code_list = [code_list.first] if code_list.size == 1 && code_list.first.empty?
234
+
235
+ occurrences_node = attr_xml.at('occurrences')
236
+ occurrences_obj = occurrences_node ? occurrences(occurrences_node) : nil
237
+
238
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Text::CCodePhrase.new(
239
+ terminology_id: terminology_id,
240
+ code_list: code_list,
241
+ path: node.path,
242
+ occurrences: occurrences_obj,
243
+ rm_type_name: 'CODE_PHRASE'
244
+ )
181
245
  end
182
246
 
183
247
  def archetype_slot(attr_xml,node)
@@ -193,22 +257,82 @@ module OpenEHR
193
257
  end
194
258
 
195
259
  def occurrences(occurrence_xml)
260
+ return nil if occurrence_xml.nil?
261
+
196
262
  lower_node = occurrence_xml.at('lower')
197
263
  upper_node = occurrence_xml.at('upper')
198
- lower = lower_node.text.to_i if lower_node
199
- upper = upper_node.text.to_i if upper_node
200
- lower_included = to_bool(occurrence_xml.at('lower_included'))
201
- upper_included = to_bool(occurrence_xml.at('upper_included'))
202
- OpenEHR::AssumedLibraryTypes::Interval.new(lower: lower, upper: upper, lower_included: lower_included, upper_included: upper_included)
264
+ lower_included_node = occurrence_xml.at('lower_included')
265
+ upper_included_node = occurrence_xml.at('upper_included')
266
+ lower_unbounded_node = occurrence_xml.at('lower_unbounded')
267
+ upper_unbounded_node = occurrence_xml.at('upper_unbounded')
268
+
269
+ lower = lower_node ? lower_node.text.to_i : nil
270
+ upper = upper_node ? upper_node.text.to_i : nil
271
+ lower_included = lower_included_node ? to_bool(lower_included_node.text) : (lower.nil? ? nil : true)
272
+ upper_included = upper_included_node ? to_bool(upper_included_node.text) : (upper.nil? ? nil : true)
273
+ lower_unbounded = lower_unbounded_node ? to_bool(lower_unbounded_node.text) : false
274
+ upper_unbounded = upper_unbounded_node ? to_bool(upper_unbounded_node.text) : false
275
+
276
+ # An occurrences element with none of its children present carries no
277
+ # constraint at all; Interval requires at least one bound, so treat
278
+ # this as "no occurrences data" rather than raising.
279
+ return nil if lower.nil? && upper.nil? && !lower_unbounded && !upper_unbounded
280
+
281
+ # Handle unbounded intervals properly
282
+ if upper_unbounded || upper.nil?
283
+ upper = nil
284
+ upper_included = nil
285
+ end
286
+
287
+ if lower_unbounded || lower.nil?
288
+ lower = nil
289
+ lower_included = nil
290
+ end
291
+
292
+ OpenEHR::AssumedLibraryTypes::Interval.new(
293
+ lower: lower,
294
+ upper: upper,
295
+ lower_included: lower_included,
296
+ upper_included: upper_included
297
+ )
203
298
  end
204
299
 
205
300
  def cardinality(xml)
206
- order = to_bool(xml.at('is_ordered').text)
207
- unique = to_bool(xml.at('is_unique').text)
208
- interval = occurrences(xml)
209
- OpenEHR::AM::Archetype::ConstraintModel::Cardinality.new(is_ordered: order, is_unique: unique, interval: interval)
301
+ return nil if xml.nil?
302
+
303
+ order_node = xml.at('is_ordered')
304
+ unique_node = xml.at('is_unique')
305
+ interval_node = xml.at('interval')
306
+
307
+ # No cardinality sub-elements at all means no cardinality data.
308
+ return nil if order_node.nil? && unique_node.nil? && interval_node.nil?
309
+
310
+ order = order_node ? to_bool(order_node.text) : false
311
+ unique = unique_node ? to_bool(unique_node.text) : false
312
+ interval = interval_node ? occurrences(interval_node) : nil
313
+
314
+ OpenEHR::AM::Archetype::ConstraintModel::Cardinality.new(
315
+ is_ordered: order,
316
+ is_unique: unique,
317
+ interval: interval
318
+ )
210
319
  end
211
320
 
321
+ def archetype_internal_ref(attr_xml, node)
322
+ rm_type_name = attr_xml.at('rm_type_name').text
323
+ target_path = attr_xml.at('target_path').text
324
+ occurrences = occurrences(attr_xml.at('occurrences'))
325
+ OpenEHR::AM::Archetype::ConstraintModel::ArchetypeInternalRef.new(rm_type_name: rm_type_name, occurrences: occurrences, target_path: target_path)
326
+ end
327
+
328
+ # No .opt fixture in this gem's corpus uses a C_DV_STATE (state
329
+ # machine) constraint, so its actual OPT XML shape is unverified;
330
+ # raising a clear, documented error here is safer than guessing
331
+ # at element names and risking a silently wrong StateMachine.
332
+ def c_dv_state(_attr_xml, _node)
333
+ raise NotImplementedError, 'OPTParser does not yet support C_DV_STATE (state machine) constraints'
334
+ end
335
+
212
336
  def constraint_ref(attr_xml, node)
213
337
  rm_type_name = attr_xml.at('rm_type_name').text
214
338
  reference = attr_xml.at('reference').text
@@ -265,9 +389,7 @@ module OpenEHR
265
389
  def c_dv_quantity(attr_xml, node)
266
390
  rm_type_name = attr_xml.at('rm_type_name').text
267
391
  occurrences = occurrences(attr_xml.at('occurrences'))
268
- property_terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: attr_xml.at('property/terminology_id/value').text)
269
- property_code_string = attr_xml.at('property/code_string').text
270
- property = OpenEHR::RM::DataTypes::Text::CodePhrase.new(terminology_id: property_terminology_id, code_string: property_code_string)
392
+ property = property_code_phrase(attr_xml.at('property'))
271
393
  list = attr_xml.xpath('.//list').map do |element|
272
394
  units = element.at('units').text if element.at('units')
273
395
  magnitude = occurrences(element.at('magnitude')) if element.at('magnitude')
@@ -277,19 +399,177 @@ module OpenEHR
277
399
  OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvQuantity.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list, property: property)
278
400
  end
279
401
 
280
- def c_date(xml)
402
+ # The <property> element is optional in real templates; return nil rather
403
+ # than dereferencing missing terminology/code nodes.
404
+ def property_code_phrase(property_xml)
405
+ return nil if property_xml.nil?
406
+ terminology_node = property_xml.at('terminology_id/value')
407
+ code_node = property_xml.at('code_string')
408
+ return nil if terminology_node.nil? || code_node.nil?
409
+ terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: terminology_node.text)
410
+ OpenEHR::RM::DataTypes::Text::CodePhrase.new(terminology_id: terminology_id, code_string: code_node.text)
411
+ end
412
+
413
+ def c_dv_ordinal(attr_xml, node)
414
+ rm_type_name = attr_xml.at('rm_type_name').text
415
+ occurrences = occurrences(attr_xml.at('occurrences'))
416
+ list = attr_xml.xpath('list').map { |element| dv_ordinal_item(element) }.compact
417
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvOrdinal.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list)
418
+ end
419
+
420
+ # DV_ORDINAL.symbol is spec'd as DV_CODED_TEXT; the OPT XML only
421
+ # carries a defining_code (terminology_id + code_string), so the
422
+ # DvCodedText's own value is set to that same code_string (there
423
+ # is no separate display text in this element).
424
+ def dv_ordinal_item(element)
425
+ value_node = element.at('value')
426
+ return nil unless value_node && !value_node.text.empty?
427
+
428
+ code_phrase = property_code_phrase(element.at('symbol/defining_code'))
429
+ return nil if code_phrase.nil?
430
+
431
+ symbol = OpenEHR::RM::DataTypes::Text::DvCodedText.new(value: code_phrase.code_string, defining_code: code_phrase)
432
+ OpenEHR::RM::DataTypes::Quantity::DvOrdinal.new(value: value_node.text.to_i, symbol: symbol)
433
+ end
281
434
 
435
+ def c_dv_scale(attr_xml, node)
436
+ rm_type_name = attr_xml.at('rm_type_name').text
437
+ occurrences = occurrences(attr_xml.at('occurrences'))
438
+ list = attr_xml.xpath('list').map { |element| dv_scale_item(element) }.compact
439
+ OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvScale.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list)
440
+ end
441
+
442
+ # Same XML shape as C_DV_ORDINAL's list items, but DV_SCALE.value
443
+ # is Real rather than Integer.
444
+ def dv_scale_item(element)
445
+ value_node = element.at('value')
446
+ return nil unless value_node && !value_node.text.empty?
447
+
448
+ code_phrase = property_code_phrase(element.at('symbol/defining_code'))
449
+ return nil if code_phrase.nil?
450
+
451
+ symbol = OpenEHR::RM::DataTypes::Text::DvCodedText.new(value: code_phrase.code_string, defining_code: code_phrase)
452
+ OpenEHR::RM::DataTypes::Quantity::DvScale.new(value: value_node.text.to_f, symbol: symbol)
453
+ end
454
+
455
+ def c_date(xml)
456
+ pattern = xml.at('pattern')
457
+ range = xml.at('range')
458
+ if pattern
459
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(pattern: pattern.text)
460
+ elsif range
461
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(range: occurrences(range))
462
+ else
463
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new
464
+ end
282
465
  end
283
466
 
284
467
  def c_date_time(xml)
285
-
468
+ pattern = xml.at('pattern')
469
+ range = xml.at('range')
470
+ if pattern
471
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(pattern: pattern.text)
472
+ elsif range
473
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(range: occurrences(range))
474
+ else
475
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new
476
+ end
286
477
  end
287
478
 
288
479
  def c_integer(xml)
480
+ range = xml.at('range')
481
+ list = xml.xpath('list')
482
+ if range
483
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(range: occurrences(range))
484
+ elsif !list.empty?
485
+ list_values = list.map { |item| item.text.to_i }
486
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(list: list_values)
487
+ else
488
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new
489
+ end
490
+ end
491
+
492
+ def c_real(xml)
493
+ range = xml.at('range')
494
+ list = xml.xpath('list')
495
+ if range
496
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(range: occurrences(range))
497
+ elsif !list.empty?
498
+ list_values = list.map { |item| item.text.to_f }
499
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(list: list_values)
500
+ else
501
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new
502
+ end
503
+ end
504
+
505
+ def c_duration(xml)
506
+ pattern = xml.at('pattern')
507
+ range_xml = xml.at('range')
508
+ list = xml.xpath('list')
509
+ if pattern
510
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(pattern: pattern.text)
511
+ elsif range_xml
512
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(range: duration_range(range_xml))
513
+ elsif !list.empty?
514
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(list: list.map(&:text))
515
+ else
516
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new
517
+ end
518
+ end
519
+
520
+ # A C_DURATION range's bounds are ISO8601 duration strings (e.g.
521
+ # PT24H), not plain numbers, so occurrences() (built for numeric
522
+ # Interval bounds) doesn't apply here; wrap each bound as a
523
+ # DV_DURATION instead, matching what CDuration#valid_value?
524
+ # already expects its range bounds to be.
525
+ def duration_range(range_xml)
526
+ lower = duration_bound(range_xml.at('lower'), range_xml.at('lower_unbounded'))
527
+ upper = duration_bound(range_xml.at('upper'), range_xml.at('upper_unbounded'))
528
+ return nil if lower.nil? && upper.nil?
529
+
530
+ OpenEHR::AssumedLibraryTypes::Interval.new(
531
+ lower: lower, upper: upper,
532
+ lower_included: lower.nil? ? nil : bool_node(range_xml.at('lower_included'), true),
533
+ upper_included: upper.nil? ? nil : bool_node(range_xml.at('upper_included'), true))
534
+ end
535
+
536
+ def duration_bound(value_node, unbounded_node)
537
+ return nil if bool_node(unbounded_node, false)
538
+ return nil if value_node.nil? || value_node.text.empty?
289
539
 
540
+ OpenEHR::RM::DataTypes::Quantity::DateTime::DvDuration.new(value: value_node.text)
541
+ end
542
+
543
+ def bool_node(node, default)
544
+ node ? to_bool(node.text) : default
545
+ end
546
+
547
+ def c_time(xml)
548
+ pattern = xml.at('pattern')
549
+ range = xml.at('range')
550
+ if pattern
551
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(pattern: pattern.text)
552
+ elsif range
553
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(range: occurrences(range))
554
+ else
555
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new
556
+ end
290
557
  end
291
558
 
292
559
  def c_boolean(xml)
560
+ true_valid = xml.at('true_valid')
561
+ false_valid = xml.at('false_valid')
562
+ assumed_value = xml.at('assumed_value')
563
+
564
+ true_valid_value = true_valid ? to_bool(true_valid.text) : nil
565
+ false_valid_value = false_valid ? to_bool(false_valid.text) : nil
566
+ assumed_value_value = assumed_value ? to_bool(assumed_value.text) : nil
567
+
568
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CBoolean.new(
569
+ true_valid: true_valid_value,
570
+ false_valid: false_valid_value,
571
+ assumed_value: assumed_value_value
572
+ )
293
573
  end
294
574
 
295
575
  def string(attr_xml)
@@ -309,12 +589,11 @@ module OpenEHR
309
589
  end
310
590
 
311
591
  def to_bool(str)
312
- if /true/i =~ str
313
- return true
314
- elsif /false/i =~ str
315
- return false
316
- end
317
- return nil
592
+ return nil if str.nil?
593
+ str = str.text if str.respond_to?(:text)
594
+ return true if /true/i =~ str.to_s
595
+ return false if /false/i =~ str.to_s
596
+ nil
318
597
  end
319
598
  end
320
599
  end
@@ -16,7 +16,9 @@ module OpenEHR
16
16
 
17
17
  end
18
18
 
19
+ require_relative 'parser/exception'
19
20
  require_relative 'parser/adl_parser'
20
21
  require_relative 'parser/opt_parser'
22
+ require_relative 'parser/archetype_validator'
21
23
  end
22
24
  end
@@ -0,0 +1,195 @@
1
+ # OpenEHR::Path parses the openEHR archetype path syntax used to
2
+ # navigate LOCATABLE/PATHABLE trees, e.g.
3
+ # /content[at0001]/data[at0002]/events[at0006]/data[at0003]/items[at0004, 'Systolic']/value
4
+ #
5
+ # Grammar subset supported (see the plan for the full rationale):
6
+ # path := '/' | ('/' segment)+
7
+ # segment := attribute predicate?
8
+ # attribute := [a-z][a-zA-Z0-9_]*
9
+ # predicate := '[' node_id (',' ws* name)? ']'
10
+ # node_id := at-code (at\d+(\.\d+)*, incl. specialised at0001.1 and
11
+ # the short at0.2 form ADL 1.4 uses for nodes newly
12
+ # introduced by a specialisation)
13
+ # | archetype-id
14
+ # name := "'" ... "'" (a Locatable#name.value literal)
15
+ #
16
+ # Deliberately unsupported (raises InvalidPathError): relative paths,
17
+ # '//' wildcards, numeric index predicates, and general expression
18
+ # predicates. Those are layered on top by consumers (e.g. AQL); this
19
+ # class only parses the path shape used by RM path navigation.
20
+ #
21
+ # This file has no dependency on any RM class, so it can be required
22
+ # standalone.
23
+ module OpenEHR
24
+ class Path
25
+ class InvalidPathError < ArgumentError; end
26
+
27
+ AT_CODE = /\Aat\d+(\.\d+)*\z/
28
+ ARCHETYPE_ID = /\A[a-zA-Z]\w+-[a-zA-Z]\w+-[a-zA-Z]\w+\.[a-zA-Z]\w+(-[a-zA-Z]\w+)?\.v\d+\z/
29
+ ATTRIBUTE = /\A[a-z][a-zA-Z0-9_]*\z/
30
+
31
+ class Segment
32
+ attr_reader :attribute, :archetype_node_id, :name
33
+
34
+ def initialize(attribute, archetype_node_id: nil, name: nil)
35
+ unless attribute.is_a?(String) && attribute =~ ATTRIBUTE
36
+ raise InvalidPathError, "invalid path attribute: #{attribute.inspect}"
37
+ end
38
+ if archetype_node_id && !(archetype_node_id =~ AT_CODE || archetype_node_id =~ ARCHETYPE_ID)
39
+ raise InvalidPathError, "invalid node id: #{archetype_node_id.inspect}"
40
+ end
41
+ raise InvalidPathError, 'name predicate requires a node_id predicate' if name && archetype_node_id.nil?
42
+
43
+ @attribute = attribute
44
+ @archetype_node_id = archetype_node_id
45
+ @name = name
46
+ freeze
47
+ end
48
+
49
+ def predicate?
50
+ !@archetype_node_id.nil?
51
+ end
52
+
53
+ def to_s
54
+ return @attribute unless predicate?
55
+
56
+ s = "#{@attribute}[#{@archetype_node_id}"
57
+ s += ", '#{@name}'" if @name
58
+ s + ']'
59
+ end
60
+
61
+ def ==(other)
62
+ other.is_a?(Segment) &&
63
+ attribute == other.attribute &&
64
+ archetype_node_id == other.archetype_node_id &&
65
+ name == other.name
66
+ end
67
+ alias eql? ==
68
+
69
+ def hash
70
+ [attribute, archetype_node_id, name].hash
71
+ end
72
+ end
73
+
74
+ def self.parse(str)
75
+ raise InvalidPathError, 'path must be a String' unless str.is_a?(String)
76
+ raise InvalidPathError, "path must start with '/': #{str.inspect}" unless str.start_with?('/')
77
+ return new([]) if str == '/'
78
+
79
+ body = str[1..-1]
80
+ raise InvalidPathError, "path must not end with '/': #{str.inspect}" if body.end_with?('/')
81
+
82
+ new(split_segments(body).map { |token| parse_segment(token) })
83
+ end
84
+
85
+ def self.valid?(str)
86
+ parse(str)
87
+ true
88
+ rescue InvalidPathError
89
+ false
90
+ end
91
+
92
+ # Splits on top-level '/' only (not '/' occurring inside a '[...]'
93
+ # predicate), and rejects empty segments (i.e. a '//' wildcard).
94
+ def self.split_segments(body)
95
+ tokens = []
96
+ depth = 0
97
+ current = +''
98
+ body.each_char do |c|
99
+ case c
100
+ when '['
101
+ depth += 1
102
+ current << c
103
+ when ']'
104
+ depth -= 1
105
+ current << c
106
+ when '/'
107
+ if depth.zero?
108
+ raise InvalidPathError, "empty path segment ('//' is not supported): #{body.inspect}" if current.empty?
109
+
110
+ tokens << current
111
+ current = +''
112
+ else
113
+ current << c
114
+ end
115
+ else
116
+ current << c
117
+ end
118
+ end
119
+ raise InvalidPathError, "unbalanced '[' in path: #{body.inspect}" unless depth.zero?
120
+ raise InvalidPathError, "empty path segment ('//' is not supported): #{body.inspect}" if current.empty?
121
+
122
+ tokens << current
123
+ tokens
124
+ end
125
+ private_class_method :split_segments
126
+
127
+ def self.parse_segment(token)
128
+ if token =~ /\A([^\[\]]+)\[(.*)\]\z/
129
+ attribute = Regexp.last_match(1)
130
+ predicate = Regexp.last_match(2)
131
+ if predicate =~ /\A([^,]+),\s*'([^']*)'\z/
132
+ Segment.new(attribute, archetype_node_id: Regexp.last_match(1).strip, name: Regexp.last_match(2))
133
+ else
134
+ Segment.new(attribute, archetype_node_id: predicate.strip)
135
+ end
136
+ elsif token =~ /\A[^\[\]]+\z/
137
+ Segment.new(token)
138
+ else
139
+ raise InvalidPathError, "malformed path segment: #{token.inspect}"
140
+ end
141
+ end
142
+ private_class_method :parse_segment
143
+
144
+ attr_reader :segments
145
+
146
+ def initialize(segments)
147
+ @segments = segments.freeze
148
+ freeze
149
+ end
150
+
151
+ def root?
152
+ @segments.empty?
153
+ end
154
+
155
+ def to_s
156
+ return '/' if root?
157
+
158
+ '/' + @segments.map(&:to_s).join('/')
159
+ end
160
+
161
+ def ==(other)
162
+ other.is_a?(Path) && segments == other.segments
163
+ end
164
+ alias eql? ==
165
+
166
+ def hash
167
+ segments.hash
168
+ end
169
+
170
+ def parent
171
+ return self if root?
172
+
173
+ self.class.new(segments[0..-2])
174
+ end
175
+
176
+ def +(other)
177
+ case other
178
+ when Segment
179
+ self.class.new(segments + [other])
180
+ when Path
181
+ self.class.new(segments + other.segments)
182
+ when String
183
+ self.class.new(segments + [Segment.new(other)])
184
+ else
185
+ raise ArgumentError, "cannot append #{other.class} to a Path"
186
+ end
187
+ end
188
+
189
+ def descend
190
+ return [nil, self] if root?
191
+
192
+ [segments.first, self.class.new(segments[1..-1])]
193
+ end
194
+ end
195
+ end