openehr 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.rdoc +93 -12
  3. data/lib/openehr/am/archetype/constraint_model/primitive.rb +204 -6
  4. data/lib/openehr/am/archetype/constraint_model.rb +228 -4
  5. data/lib/openehr/am/archetype/ontology.rb +2 -2
  6. data/lib/openehr/am/archetype.rb +101 -2
  7. data/lib/openehr/am/openehr_profile/data_types/basic.rb +28 -0
  8. data/lib/openehr/am/openehr_profile/data_types/quantity.rb +88 -0
  9. data/lib/openehr/am/openehr_profile/data_types/text.rb +23 -0
  10. data/lib/openehr/am/template.rb +25 -3
  11. data/lib/openehr/aql/engine/binding.rb +13 -0
  12. data/lib/openehr/aql/engine/contains_resolver.rb +161 -0
  13. data/lib/openehr/aql/engine/dataset.rb +122 -0
  14. data/lib/openehr/aql/engine/path_evaluator.rb +137 -0
  15. data/lib/openehr/aql/engine/predicate_evaluator.rb +65 -0
  16. data/lib/openehr/aql/engine.rb +119 -0
  17. data/lib/openehr/aql/errors.rb +27 -0
  18. data/lib/openehr/aql/lexer.rb +295 -0
  19. data/lib/openehr/aql/model/containment.rb +45 -0
  20. data/lib/openehr/aql/model/from_clause.rb +35 -0
  21. data/lib/openehr/aql/model/function_call.rb +56 -0
  22. data/lib/openehr/aql/model/identified_path.rb +20 -0
  23. data/lib/openehr/aql/model/literal.rb +15 -0
  24. data/lib/openehr/aql/model/object_path.rb +34 -0
  25. data/lib/openehr/aql/model/order_by_and_limit.rb +50 -0
  26. data/lib/openehr/aql/model/predicate.rb +84 -0
  27. data/lib/openehr/aql/model/query.rb +24 -0
  28. data/lib/openehr/aql/model/select_clause.rb +29 -0
  29. data/lib/openehr/aql/model/where_clause.rb +105 -0
  30. data/lib/openehr/aql/model.rb +14 -0
  31. data/lib/openehr/aql/parser.rb +484 -0
  32. data/lib/openehr/aql/result_set.rb +54 -0
  33. data/lib/openehr/aql.rb +28 -0
  34. data/lib/openehr/assumed_library_types.rb +68 -17
  35. data/lib/openehr/parser/adl_grammar.tt +29 -13
  36. data/lib/openehr/parser/adl_parser.rb +18 -4
  37. data/lib/openehr/parser/archetype_validator.rb +126 -0
  38. data/lib/openehr/parser/exception.rb +13 -0
  39. data/lib/openehr/parser/opt_parser.rb +162 -9
  40. data/lib/openehr/parser.rb +2 -0
  41. data/lib/openehr/path.rb +195 -0
  42. data/lib/openehr/rm/common/archetyped.rb +116 -6
  43. data/lib/openehr/rm/common/change_control.rb +3 -8
  44. data/lib/openehr/rm/common/directory.rb +4 -0
  45. data/lib/openehr/rm/common/generic.rb +24 -10
  46. data/lib/openehr/rm/composition/content/entry.rb +24 -5
  47. data/lib/openehr/rm/composition/content/navigation.rb +2 -1
  48. data/lib/openehr/rm/composition.rb +19 -2
  49. data/lib/openehr/rm/data_structures/history.rb +3 -0
  50. data/lib/openehr/rm/data_structures/item_structure/representation.rb +13 -9
  51. data/lib/openehr/rm/data_structures/item_structure.rb +28 -15
  52. data/lib/openehr/rm/data_types/encapsulated.rb +23 -3
  53. data/lib/openehr/rm/data_types/quantity/date_time.rb +9 -1
  54. data/lib/openehr/rm/data_types/quantity.rb +66 -14
  55. data/lib/openehr/rm/data_types/text.rb +8 -0
  56. data/lib/openehr/rm/data_types/time_specification.rb +5 -4
  57. data/lib/openehr/rm/demographic.rb +4 -3
  58. data/lib/openehr/rm/ehr.rb +20 -1
  59. data/lib/openehr/rm/factory.rb +234 -6
  60. data/lib/openehr/rm/integration.rb +1 -0
  61. data/lib/openehr/rm/support/identification.rb +37 -8
  62. data/lib/openehr/rm/support/measurement.rb +32 -0
  63. data/lib/openehr/rm/type_name.rb +90 -0
  64. data/lib/openehr/rm.rb +3 -0
  65. data/lib/openehr/serializer/adl_serializer.rb +335 -0
  66. data/lib/openehr/serializer/base.rb +20 -0
  67. data/lib/openehr/serializer/opt_serializer.rb +49 -0
  68. data/lib/openehr/serializer/rm_json_serializer.rb +62 -0
  69. data/lib/openehr/serializer/xml_serializer.rb +260 -0
  70. data/lib/openehr/serializer.rb +5 -291
  71. data/lib/openehr/terminology_service.rb +44 -0
  72. data/lib/openehr/version.rb +1 -1
  73. data/lib/openehr.rb +5 -2
  74. metadata +35 -7
  75. data/lib/openehr/parser/validator.rb +0 -18
  76. data/lib/openehr/parser/xml_parser.rb +0 -13
  77. data/lib/openehr/rm/data_types/charset_extract.rb +0 -24
  78. data/lib/openehr/writer.rb +0 -12
@@ -71,6 +71,35 @@ module OpenEHR
71
71
  (@lower_included == value.lower_included?) &&
72
72
  (@upper_included == value.upper_included?)
73
73
  end
74
+
75
+ # True if every value this interval has is also had by other (a
76
+ # standard subset test, e.g. for conformance checks: does a
77
+ # constraint's occurrences/existence/cardinality interval fit
78
+ # within its parent's).
79
+ def subset_of?(other)
80
+ return false if other.nil?
81
+
82
+ lower_ok = if lower_unbounded?
83
+ other.lower_unbounded?
84
+ elsif other.lower_unbounded? || lower > other.lower
85
+ true
86
+ elsif lower == other.lower
87
+ !lower_included? || other.lower_included?
88
+ else
89
+ false
90
+ end
91
+ upper_ok = if upper_unbounded?
92
+ other.upper_unbounded?
93
+ elsif other.upper_unbounded? || upper < other.upper
94
+ true
95
+ elsif upper == other.upper
96
+ !upper_included? || other.upper_included?
97
+ else
98
+ false
99
+ end
100
+ lower_ok && upper_ok
101
+ end
102
+
74
103
  private
75
104
 
76
105
  def check_lower_upper(lower, upper)
@@ -441,6 +470,8 @@ module OpenEHR
441
470
  return false
442
471
  end
443
472
  end
473
+ # A timezone is optional in ISO 8601; its absence does not make an
474
+ # otherwise-valid time invalid.
444
475
  unless tz.nil?
445
476
  timezone = Timezone.new(tz)
446
477
  if timezone.hour < 0 or timezone.hour >= HOURS_IN_DAY
@@ -449,10 +480,8 @@ module OpenEHR
449
480
  if timezone.minute < 0 or timezone.minute >= MINUTES_IN_HOUR
450
481
  return false
451
482
  end
452
- return true
453
- else
454
- return false
455
483
  end
484
+ return true
456
485
  end
457
486
  end
458
487
  end # end of ISO8601_TIME
@@ -589,6 +618,19 @@ module OpenEHR
589
618
  include TimeDefinitions
590
619
  attr_reader :years, :months, :weeks, :days
591
620
  attr_reader :hours, :minutes, :seconds, :fractional_second
621
+ # RM 1.1.0 (SPECRM-96): a leading '-' before 'P' negates the whole
622
+ # duration (e.g. "-P10D", 10 days before an unstated origin point).
623
+ # Components stay non-negative magnitudes - the sign applies to
624
+ # the duration as a whole, not each field - so it's tracked here.
625
+ attr_reader :negative
626
+
627
+ def negative=(negative)
628
+ @negative = negative ? true : false
629
+ end
630
+
631
+ def negative?
632
+ @negative == true
633
+ end
592
634
 
593
635
  def years=(years)
594
636
  unless years.nil? || years >= 0
@@ -647,7 +689,7 @@ module OpenEHR
647
689
  end
648
690
 
649
691
  def as_string
650
- str = 'P'
692
+ str = negative? ? '-P' : 'P'
651
693
  unless @years.nil?
652
694
  str += @years.to_s + 'Y'
653
695
  end
@@ -681,27 +723,36 @@ module OpenEHR
681
723
  nilthenzero(@months)*TimeDefinitions::DAYS_IN_MONTH +
682
724
  nilthenzero(@weeks)*TimeDefinitions::DAYS_IN_WEEK +
683
725
  nilthenzero(@days)
684
- seconds_with_fractional = (((days*TimeDefinitions::HOURS_IN_DAY + nilthenzero(@hours))*TimeDefinitions::MINUTES_IN_HOUR)+nilthenzero(@minutes))*TimeDefinitions::SECONDS_IN_MINUTE +
726
+ seconds_with_fractional = (((days*TimeDefinitions::HOURS_IN_DAY + nilthenzero(@hours))*TimeDefinitions::MINUTES_IN_HOUR)+nilthenzero(@minutes))*TimeDefinitions::SECONDS_IN_MINUTE +
685
727
  nilthenzero(@seconds) +
686
728
  @fractional_second.to_f
687
- return seconds_with_fractional
729
+ return negative? ? -seconds_with_fractional : seconds_with_fractional
688
730
  end
689
731
  end
690
732
 
691
733
  class ISO8601Duration
692
734
  include ISO8601DurationModule, Comparable
693
735
  def initialize(str)
694
- /^P((\d+)[Yy])?((\d+)[Mm])?((\d+)[Ww])?((\d)[dD])?(T((\d+)[Hh])?((\d+)[Mm])?((\d+)(\.\d+)?[Ss])?)?$/ =~ str
695
- self.years = $2.to_i
696
- self.months = $4.to_i
697
- self.weeks = $6.to_i
698
- self.days = $8.to_i
699
- self.hours = $11.to_i
700
- self.minutes = $13.to_i
701
- self.seconds = $15.to_i
702
- unless $16.nil?
703
- self.fractional_second = $16.to_f
704
- end
736
+ # A leading '-' negates the whole duration (RM 1.1.0, SPECRM-96);
737
+ # stripped before the main match so the existing capture group
738
+ # numbering below doesn't need to shift.
739
+ self.negative = str.start_with?('-')
740
+ duration_str = negative? ? str[1..-1] : str
741
+ md = /^P((\d+)[Yy])?((\d+)[Mm])?((\d+)[Ww])?((\d+)[dD])?(T((\d+)[Hh])?((\d+)[Mm])?((\d+)(\.\d+)?[Ss])?)?$/.match(duration_str)
742
+ if md.nil?
743
+ raise ArgumentError, 'invalid ISO8601 duration format'
744
+ end
745
+ # Absent components stay nil rather than being coerced to 0, so
746
+ # as_string can round-trip a partially-specified duration and
747
+ # "unset" is distinguishable from "explicitly zero".
748
+ self.years = md[2] && md[2].to_i
749
+ self.months = md[4] && md[4].to_i
750
+ self.weeks = md[6] && md[6].to_i
751
+ self.days = md[8] && md[8].to_i
752
+ self.hours = md[11] && md[11].to_i
753
+ self.minutes = md[13] && md[13].to_i
754
+ self.seconds = md[15] && md[15].to_i
755
+ self.fractional_second = md[16] && md[16].to_f
705
756
  end
706
757
 
707
758
  def <=>(other)
@@ -15,7 +15,7 @@ module OpenEHR
15
15
  lang:(arch_language)?
16
16
  desc:(arch_description)?
17
17
  arch_definition
18
- arch_invariant?
18
+ inv:(arch_invariant)?
19
19
  arch_ontology {
20
20
  def archetype_id
21
21
  arch_identification.archetype_id
@@ -52,6 +52,14 @@ module OpenEHR
52
52
  def ontology
53
53
  arch_ontology.value
54
54
  end
55
+
56
+ def parent_archetype_id
57
+ spec.archetype_id unless spec.empty?
58
+ end
59
+
60
+ def invariants
61
+ inv.value unless inv.empty?
62
+ end
55
63
  }
56
64
  end
57
65
 
@@ -116,9 +124,9 @@ module OpenEHR
116
124
  { :uid => uid.value }
117
125
  end
118
126
  }
119
- / SYM_IS_CONTROLED space {
127
+ / SYM_IS_CONTROLLED space {
120
128
  def value
121
- { :is_controled? => true } # If elements[0]
129
+ { :is_controlled? => true } # If elements[0]
122
130
  end
123
131
  }
124
132
  end
@@ -194,7 +202,11 @@ module OpenEHR
194
202
  end
195
203
 
196
204
  rule arch_invariant
197
- SYM_INVARIANT V_ASSERTION_TEXT space
205
+ SYM_INVARIANT text:V_ASSERTION_TEXT space {
206
+ def value
207
+ text.value
208
+ end
209
+ }
198
210
  end
199
211
 
200
212
  rule arch_ontology
@@ -327,7 +339,7 @@ module OpenEHR
327
339
  rule c_complex_object_body
328
340
  c_any '' {
329
341
  def value(node)
330
- Hash[:attributes => [c_any.value(node)]]
342
+ Hash[:attributes => nil]
331
343
  end
332
344
  }
333
345
  / c_attributes '' {
@@ -386,10 +398,10 @@ module OpenEHR
386
398
  }
387
399
  / V_C_DOMAIN_TYPE '' {
388
400
  def value(node = nil)
389
- p elemetns
401
+ raise OpenEHR::Parser::ParseError,
402
+ "generic C_DOMAIN_TYPE constraints (e.g. C_DV_STATE) are not yet supported by the ADL parser: #{text_value}"
390
403
  end
391
404
  }
392
- # / ERR_V_C_DOMAIN_TYPE
393
405
  end
394
406
 
395
407
  rule archetype_internal_ref
@@ -1460,7 +1472,7 @@ module OpenEHR
1460
1472
  def value
1461
1473
  if (id && !id.empty?)
1462
1474
  OpenEHR::AM::Archetype::Assertion::Assertion.new(
1463
- :tag => id.value, :expression => boolean_expression.value,
1475
+ :tag => id.elements[0].value, :expression => boolean_expression.value,
1464
1476
  :string_expression => id.text_value + boolean_expression.text_value)
1465
1477
  else
1466
1478
  OpenEHR::AM::Archetype::Assertion::Assertion.new(
@@ -1726,7 +1738,7 @@ module OpenEHR
1726
1738
  }
1727
1739
  / integer_value '' {
1728
1740
  def value
1729
- OpenEHR::AM::Archetype::ConstraintModel::ExprLeaf.new(
1741
+ OpenEHR::AM::Archetype::Assertion::ExprLeaf.new(
1730
1742
  :type => 'Integer',
1731
1743
  :item => integer_value.value,
1732
1744
  :reference_type => 'CONSTANT')
@@ -1734,7 +1746,7 @@ module OpenEHR
1734
1746
  }
1735
1747
  / real_value '' {
1736
1748
  def value
1737
- OpenEHR::AM::Archetype::ConstraintModel::ExprLeaf.new(
1749
+ OpenEHR::AM::Archetype::Assertion::ExprLeaf.new(
1738
1750
  :type => 'Real',
1739
1751
  :item => real_value.value,
1740
1752
  :reference_type => 'CONSTANT')
@@ -1742,7 +1754,7 @@ module OpenEHR
1742
1754
  }
1743
1755
  / absolute_path space {
1744
1756
  def value
1745
- OpenEHR::AM::Archetype::ConstraintModel::ExprLeaf.new(
1757
+ OpenEHR::AM::Archetype::Assertion::ExprLeaf.new(
1746
1758
  :type => 'String',
1747
1759
  :item => absolute_path.value,
1748
1760
  :reference_type => 'CONSTANT')
@@ -2168,9 +2180,9 @@ module OpenEHR
2168
2180
  type_identifier.value
2169
2181
  end
2170
2182
  }
2171
- / V_ATTRIBUTE_IDENTIFIER '' {
2183
+ / id:V_ATTRIBUTE_IDENTIFIER '' {
2172
2184
  def value
2173
- V_ATTRIBUTE_IDENTIFIER.value
2185
+ id.value
2174
2186
  end
2175
2187
  }
2176
2188
  end
@@ -3004,6 +3016,10 @@ module OpenEHR
3004
3016
  '=' white_space
3005
3017
  end
3006
3018
 
3019
+ rule SYM_NE
3020
+ '!=' white_space
3021
+ end
3022
+
3007
3023
  rule SYM_GE
3008
3024
  ('=>' / '>=') white_space
3009
3025
  end
@@ -13,8 +13,10 @@ module OpenEHR
13
13
  super
14
14
  end
15
15
 
16
- def parse
17
- archetype
16
+ def parse(validate: false)
17
+ archetype.tap do |a|
18
+ ArchetypeValidator.new(a).validate! if validate
19
+ end
18
20
  end
19
21
 
20
22
  private
@@ -67,6 +69,16 @@ module OpenEHR
67
69
  parsed_data.concept
68
70
  end
69
71
 
72
+ def parent_archetype_id
73
+ if parsed_data.parent_archetype_id
74
+ OpenEHR::RM::Support::Identification::ArchetypeID.new(:value => parsed_data.parent_archetype_id)
75
+ end
76
+ end
77
+
78
+ def invariants
79
+ parsed_data.invariants
80
+ end
81
+
70
82
  def description
71
83
  parsed_data.description
72
84
  end
@@ -82,13 +94,15 @@ module OpenEHR
82
94
  def archetype
83
95
  OpenEHR::AM::Archetype::Archetype.new(:archetype_id => archetype_id,
84
96
  :adl_version => adl_version,
85
- :uid => uid,
97
+ :uid => uid,
86
98
  :concept => concept,
87
99
  :original_language => original_language,
88
100
  :translations => translations,
89
101
  :description => description,
90
102
  :definition => definition,
91
- :ontology => ontology)
103
+ :ontology => ontology,
104
+ :parent_archetype_id => parent_archetype_id,
105
+ :invariants => invariants)
92
106
  end
93
107
  end
94
108
  end # of Parser
@@ -0,0 +1,126 @@
1
+ require_relative 'exception'
2
+
3
+ module OpenEHR
4
+ module Parser
5
+ # Post-parse semantic validator, checking a parsed archetype
6
+ # against the VARID..VDFPT rules (Beale07, p.100). VARID/VARDF/
7
+ # VARON/VDFAI are already enforced structurally by the RM/AM
8
+ # object model itself (their setters raise on construction), so
9
+ # they can never actually fail here - they're still checked
10
+ # explicitly, both for rule-traceability and so a validator run
11
+ # against a duck-typed object (not necessarily a real
12
+ # OpenEHR::AM::Archetype::Archetype) stays honest.
13
+ class ArchetypeValidator
14
+ Validation = Exception::Validation
15
+
16
+ def initialize(archetype)
17
+ @archetype = archetype
18
+ end
19
+
20
+ def errors
21
+ RULES.filter_map { |rule| send(rule) }
22
+ end
23
+
24
+ def valid?
25
+ errors.empty?
26
+ end
27
+
28
+ def validate!
29
+ error = errors.first
30
+ raise error if error
31
+ end
32
+
33
+ # Checks +rm_root+ against the archetype's definition, returning
34
+ # a path-annotated OpenEHR::Parser::Exception::Validation::
35
+ # InstanceNonConformant for the first violation found (path
36
+ # resolved via rm_root.path_of_item, the PATHABLE navigation
37
+ # method), or [] when rm_root fully conforms.
38
+ def validate_instance(rm_root)
39
+ return [] if @archetype.definition.valid_value?(rm_root)
40
+
41
+ _node, value = @archetype.definition.find_violation(rm_root)
42
+ message = "value does not conform to #{@archetype.archetype_id.value}"
43
+ [Validation::InstanceNonConformant.new(message, resolve_path(rm_root, value))]
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_path(rm_root, value)
49
+ return nil unless rm_root.respond_to?(:path_of_item)
50
+
51
+ rm_root.path_of_item(value)
52
+ end
53
+
54
+ def varid
55
+ Validation::VARID.new(Validation::VARID::MESSAGE) if @archetype.archetype_id.nil?
56
+ end
57
+
58
+ def vardf
59
+ Validation::VARDF.new(Validation::VARDF::MESSAGE) if @archetype.definition.nil?
60
+ end
61
+
62
+ def varon
63
+ Validation::VARON.new(Validation::VARON::MESSAGE) if @archetype.ontology.nil?
64
+ end
65
+
66
+ def varcn
67
+ return if @archetype.concept.nil?
68
+ return if @archetype.ontology.term_codes.include?(@archetype.concept)
69
+
70
+ Validation::VARCN.new(Validation::VARCN::MESSAGE)
71
+ end
72
+
73
+ def vardt
74
+ definition_type = @archetype.definition.rm_type_name.to_s.upcase
75
+ id_type = @archetype.archetype_id.rm_entity.to_s.upcase
76
+ return if definition_type == id_type
77
+
78
+ Validation::VARDT.new(Validation::VARDT::MESSAGE)
79
+ end
80
+
81
+ def vatdf
82
+ Validation::VATDF.new(Validation::VATDF::MESSAGE) unless @archetype.node_ids_valid?
83
+ end
84
+
85
+ def vacdf
86
+ Validation::VACDF.new(Validation::VACDF::MESSAGE) unless @archetype.constraint_references_valid?
87
+ end
88
+
89
+ # In practice this can never actually fail through this gem's own
90
+ # object model: CArchetypeRoot#archetype_id= already rejects
91
+ # anything but a well-typed ArchetypeID at construction time.
92
+ # Still checked genuinely (rather than a hardcoded pass) in case
93
+ # +archetype+ is a duck-typed object built some other way.
94
+ def vdfai
95
+ return if archetype_roots(@archetype.definition).all? do |id|
96
+ id.is_a?(OpenEHR::RM::Support::Identification::ArchetypeID)
97
+ end
98
+
99
+ Validation::VDFAI.new(Validation::VDFAI::MESSAGE)
100
+ end
101
+
102
+ def archetype_roots(node, found = [])
103
+ return found if node.nil?
104
+
105
+ found << node.archetype_id if node.is_a?(OpenEHR::AM::Archetype::ConstraintModel::CArchetypeRoot)
106
+ if node.respond_to?(:attributes) && node.attributes
107
+ node.attributes.each { |attribute| archetype_roots(attribute, found) }
108
+ end
109
+ if node.respond_to?(:children) && node.children
110
+ node.children.each { |child| archetype_roots(child, found) }
111
+ end
112
+ found
113
+ end
114
+
115
+ def vdfpt
116
+ return if @archetype.internal_references_valid? &&
117
+ @archetype.physical_paths.all? { |path| OpenEHR::Path.valid?(path) }
118
+
119
+ Validation::VDFPT.new(Validation::VDFPT::MESSAGE)
120
+ end
121
+
122
+ RULES = %i[varid vardf varon varcn vardt vatdf vacdf vdfai vdfpt].freeze
123
+ private_constant :RULES
124
+ end
125
+ end
126
+ end
@@ -60,6 +60,19 @@ module OpenEHR
60
60
  class VDFPT < Base
61
61
  MESSAGE = "Any path mentioned in the definition section must be valid syntactically, and a valid path with respect to the hierarchical structure of the definition section."
62
62
  end
63
+
64
+ # Raised by ArchetypeValidator#validate_instance for an RM
65
+ # instance that does not conform to an archetype's definition;
66
+ # not one of the VARID..VDFPT archetype-structure rules, since
67
+ # this checks an RM instance against the archetype instead.
68
+ class InstanceNonConformant < Base
69
+ attr_reader :path
70
+
71
+ def initialize(message, path = nil)
72
+ super(message)
73
+ @path = path
74
+ end
75
+ end
63
76
  end
64
77
 
65
78
  end
@@ -40,7 +40,7 @@ module OpenEHR
40
40
  @opt = Nokogiri::XML::Document.parse(File.open(@filename))
41
41
  @opt.remove_namespaces!
42
42
 
43
- uid = OpenEHR::RM::Support::Identification::UIDBasedID.new(value: text_on_path(@opt, UID_PATH))
43
+ uid = build_uid
44
44
  defs = definition
45
45
 
46
46
  # Create operational template with archetype-compatible parameters
@@ -52,7 +52,7 @@ module OpenEHR
52
52
  template_id: template_id,
53
53
  archetype_id: template_id, # Use template_id as archetype_id for compatibility
54
54
  definition: defs,
55
- ontology: create_template_ontology,
55
+ ontology: (@component_terminologies || {})[defs.archetype_id.value] || create_template_ontology,
56
56
  component_terminologies: @component_terminologies || {},
57
57
  terminology_extracts: @component_terminologies || {},
58
58
  adl_version: "1.4"
@@ -61,8 +61,20 @@ module OpenEHR
61
61
 
62
62
  private
63
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.
64
67
  def template_id
65
- @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)
66
78
  end
67
79
 
68
80
  def concept
@@ -260,7 +272,12 @@ module OpenEHR
260
272
  upper_included = upper_included_node ? to_bool(upper_included_node.text) : (upper.nil? ? nil : true)
261
273
  lower_unbounded = lower_unbounded_node ? to_bool(lower_unbounded_node.text) : false
262
274
  upper_unbounded = upper_unbounded_node ? to_bool(upper_unbounded_node.text) : false
263
-
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
+
264
281
  # Handle unbounded intervals properly
265
282
  if upper_unbounded || upper.nil?
266
283
  upper = nil
@@ -282,11 +299,14 @@ module OpenEHR
282
299
 
283
300
  def cardinality(xml)
284
301
  return nil if xml.nil?
285
-
302
+
286
303
  order_node = xml.at('is_ordered')
287
304
  unique_node = xml.at('is_unique')
288
305
  interval_node = xml.at('interval')
289
-
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
+
290
310
  order = order_node ? to_bool(order_node.text) : false
291
311
  unique = unique_node ? to_bool(unique_node.text) : false
292
312
  interval = interval_node ? occurrences(interval_node) : nil
@@ -298,6 +318,21 @@ module OpenEHR
298
318
  )
299
319
  end
300
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
+
301
336
  def constraint_ref(attr_xml, node)
302
337
  rm_type_name = attr_xml.at('rm_type_name').text
303
338
  reference = attr_xml.at('reference').text
@@ -354,9 +389,7 @@ module OpenEHR
354
389
  def c_dv_quantity(attr_xml, node)
355
390
  rm_type_name = attr_xml.at('rm_type_name').text
356
391
  occurrences = occurrences(attr_xml.at('occurrences'))
357
- property_terminology_id = OpenEHR::RM::Support::Identification::TerminologyID.new(value: attr_xml.at('property/terminology_id/value').text)
358
- property_code_string = attr_xml.at('property/code_string').text
359
- 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'))
360
393
  list = attr_xml.xpath('.//list').map do |element|
361
394
  units = element.at('units').text if element.at('units')
362
395
  magnitude = occurrences(element.at('magnitude')) if element.at('magnitude')
@@ -366,6 +399,59 @@ module OpenEHR
366
399
  OpenEHR::AM::OpenEHRProfile::DataTypes::Quantity::CDvQuantity.new(rm_type_name: rm_type_name, occurrences: occurrences, list: list, property: property)
367
400
  end
368
401
 
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
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
+
369
455
  def c_date(xml)
370
456
  pattern = xml.at('pattern')
371
457
  range = xml.at('range')
@@ -403,6 +489,73 @@ module OpenEHR
403
489
  end
404
490
  end
405
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?
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
557
+ end
558
+
406
559
  def c_boolean(xml)
407
560
  true_valid = xml.at('true_valid')
408
561
  false_valid = xml.at('false_valid')