openehr 2.1.0 → 2.3.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.
@@ -0,0 +1,145 @@
1
+ module OpenEHR
2
+ module Parser
3
+ # The 8 C_PRIMITIVE readers (boolean/string/integer/real/date/
4
+ # date_time/time/duration), shared between OPTParser and
5
+ # XMLArchetypeParser - both read a <item xsi:type="C_*">-wrapped
6
+ # element with the same internal shape. Extracted from opt_parser.rb
7
+ # without behavior changes, except C_REAL's range now reads Float
8
+ # bounds instead of silently truncating them to Integer via
9
+ # numeric_interval(real: true) (see xml_constraint_parsing.rb).
10
+ module XMLPrimitiveParsing
11
+ private
12
+
13
+ def c_date(xml)
14
+ pattern = xml.at('pattern')
15
+ range = xml.at('range')
16
+ if pattern
17
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(pattern: pattern.text)
18
+ elsif range
19
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(range: occurrences(range))
20
+ else
21
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new
22
+ end
23
+ end
24
+
25
+ def c_date_time(xml)
26
+ pattern = xml.at('pattern')
27
+ range = xml.at('range')
28
+ if pattern
29
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(pattern: pattern.text)
30
+ elsif range
31
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(range: occurrences(range))
32
+ else
33
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new
34
+ end
35
+ end
36
+
37
+ def c_integer(xml)
38
+ range = xml.at('range')
39
+ list = xml.xpath('list')
40
+ if range
41
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(range: occurrences(range))
42
+ elsif !list.empty?
43
+ list_values = list.map { |item| item.text.to_i }
44
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(list: list_values)
45
+ else
46
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new
47
+ end
48
+ end
49
+
50
+ def c_real(xml)
51
+ range = xml.at('range')
52
+ list = xml.xpath('list')
53
+ if range
54
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(range: numeric_interval(range, real: true))
55
+ elsif !list.empty?
56
+ list_values = list.map { |item| item.text.to_f }
57
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(list: list_values)
58
+ else
59
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new
60
+ end
61
+ end
62
+
63
+ def c_duration(xml)
64
+ pattern = xml.at('pattern')
65
+ range_xml = xml.at('range')
66
+ list = xml.xpath('list')
67
+ if pattern
68
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(pattern: pattern.text)
69
+ elsif range_xml
70
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(range: duration_range(range_xml))
71
+ elsif !list.empty?
72
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(list: list.map(&:text))
73
+ else
74
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new
75
+ end
76
+ end
77
+
78
+ # A C_DURATION range's bounds are ISO8601 duration strings (e.g.
79
+ # PT24H), not plain numbers, so occurrences() (built for numeric
80
+ # Interval bounds) doesn't apply here; wrap each bound as a
81
+ # DV_DURATION instead, matching what CDuration#valid_value?
82
+ # already expects its range bounds to be.
83
+ def duration_range(range_xml)
84
+ lower = duration_bound(range_xml.at('lower'), range_xml.at('lower_unbounded'))
85
+ upper = duration_bound(range_xml.at('upper'), range_xml.at('upper_unbounded'))
86
+ return nil if lower.nil? && upper.nil?
87
+
88
+ OpenEHR::AssumedLibraryTypes::Interval.new(
89
+ lower: lower, upper: upper,
90
+ lower_included: lower.nil? ? nil : bool_node(range_xml.at('lower_included'), true),
91
+ upper_included: upper.nil? ? nil : bool_node(range_xml.at('upper_included'), true))
92
+ end
93
+
94
+ def duration_bound(value_node, unbounded_node)
95
+ return nil if bool_node(unbounded_node, false)
96
+ return nil if value_node.nil? || value_node.text.empty?
97
+
98
+ OpenEHR::RM::DataTypes::Quantity::DateTime::DvDuration.new(value: value_node.text)
99
+ end
100
+
101
+ def bool_node(node, default)
102
+ node ? to_bool(node.text) : default
103
+ end
104
+
105
+ def c_time(xml)
106
+ pattern = xml.at('pattern')
107
+ range = xml.at('range')
108
+ if pattern
109
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(pattern: pattern.text)
110
+ elsif range
111
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(range: occurrences(range))
112
+ else
113
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new
114
+ end
115
+ end
116
+
117
+ def c_boolean(xml)
118
+ true_valid = xml.at('true_valid')
119
+ false_valid = xml.at('false_valid')
120
+ assumed_value = xml.at('assumed_value')
121
+
122
+ true_valid_value = true_valid ? to_bool(true_valid.text) : nil
123
+ false_valid_value = false_valid ? to_bool(false_valid.text) : nil
124
+ assumed_value_value = assumed_value ? to_bool(assumed_value.text) : nil
125
+
126
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CBoolean.new(
127
+ true_valid: true_valid_value,
128
+ false_valid: false_valid_value,
129
+ assumed_value: assumed_value_value
130
+ )
131
+ end
132
+
133
+ def c_string(attr_xml)
134
+ if attr_xml.at('pattern')
135
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CString.new(pattern: attr_xml.at('pattern').text)
136
+ else
137
+ list = attr_xml.xpath('.//list').map do |str|
138
+ str.text
139
+ end
140
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CString.new(list: list)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -19,6 +19,7 @@ module OpenEHR
19
19
  require_relative 'parser/exception'
20
20
  require_relative 'parser/adl_parser'
21
21
  require_relative 'parser/opt_parser'
22
+ require_relative 'parser/xml_archetype_parser'
22
23
  require_relative 'parser/archetype_validator'
23
24
  end
24
25
  end
@@ -167,8 +167,8 @@ module OpenEHR
167
167
  end
168
168
 
169
169
  class Participation
170
- attr_reader :performer, :function, :mode
171
- attr_accessor :time
170
+ attr_reader :performer, :function
171
+ attr_accessor :time, :mode
172
172
 
173
173
  def initialize(args ={ })
174
174
  self.performer = args[:performer]
@@ -186,11 +186,8 @@ module OpenEHR
186
186
  raise ArgumentError, 'function is mandatory' if function.nil?
187
187
  @function = function
188
188
  end
189
-
190
- def mode=(mode)
191
- raise ArgumentError, 'mode is mandatory' if mode.nil?
192
- @mode = mode
193
- end
189
+ # mode is optional (0..1) since RM 1.0.4 - attr_accessor above
190
+ # covers it, no validation needed.
194
191
  end
195
192
 
196
193
  class Attestation < AuditDetails
@@ -258,9 +258,12 @@ module OpenEHR
258
258
  @current_state = current_state
259
259
  end
260
260
 
261
+ # transition is optional (0..1) since RM 1.1.0; when present
262
+ # its code is still validated against the terminology.
261
263
  def transition=(transition)
262
264
  if transition.nil?
263
- raise ArgumentError, 'transition is mandatory'
265
+ @transition = nil
266
+ return
264
267
  end
265
268
  unless OpenEHR::TerminologyService.has_code_for_group?('ism transition careflow transition', transition.defining_code.code_string)
266
269
  raise ArgumentError, 'transition code is invalid'
@@ -21,12 +21,13 @@ module OpenEHR
21
21
  NULL_FLAVOUR_CODES = %w[271 272 273 253].freeze
22
22
 
23
23
  attr_accessor :value
24
- attr_reader :null_flavor
25
- path_attribute :value, :null_flavour
24
+ attr_reader :null_flavor, :null_reason
25
+ path_attribute :value, :null_flavour, :null_reason
26
26
  def initialize(args = {})
27
27
  super(args)
28
28
  self.value = args[:value]
29
29
  self.null_flavor= args[:null_flavor]
30
+ self.null_reason = args[:null_reason]
30
31
  end
31
32
 
32
33
  def null_flavor=(null_flavor)
@@ -40,6 +41,15 @@ module OpenEHR
40
41
  alias null_flavour null_flavor
41
42
  alias null_flavour= null_flavor=
42
43
 
44
+ # null_reason is DV_TEXT [0..1] since RM 1.1.0. Invariant: if
45
+ # set, null_flavor must also be set.
46
+ def null_reason=(null_reason)
47
+ if !null_reason.nil? && @null_flavor.nil?
48
+ raise ArgumentError, 'null_reason requires null_flavor to be set'
49
+ end
50
+ @null_reason = null_reason
51
+ end
52
+
43
53
  def is_null?
44
54
  return @value.nil?
45
55
  end
@@ -75,17 +75,17 @@ module OpenEHR
75
75
  self.type = args[:type]
76
76
  end
77
77
 
78
+ # issuer/assigner/type are optional (0..1) since RM 1.0.4 - only
79
+ # id is mandatory. A non-nil value must still not be empty.
78
80
  def issuer=(issuer)
79
- if issuer.nil? or issuer.empty?
80
- raise ArgumentError, 'issuer is mandatory'
81
- end
81
+ raise ArgumentError, 'issuer must not be empty' if !issuer.nil? && issuer.empty?
82
+
82
83
  @issuer = issuer
83
84
  end
84
85
 
85
86
  def assigner=(assigner)
86
- if assigner.nil? or assigner.empty?
87
- raise ArgumentError, 'assigner is mandatory'
88
- end
87
+ raise ArgumentError, 'assigner must not be empty' if !assigner.nil? && assigner.empty?
88
+
89
89
  @assigner = assigner
90
90
  end
91
91
 
@@ -97,9 +97,8 @@ module OpenEHR
97
97
  end
98
98
 
99
99
  def type=(type)
100
- if type.nil? or type.empty?
101
- raise ArgumentError, 'type is mandatory'
102
- end
100
+ raise ArgumentError, 'type must not be empty' if !type.nil? && type.empty?
101
+
103
102
  @type = type
104
103
  end
105
104
  end #end of DvIdentifier
@@ -65,6 +65,7 @@ module OpenEHR
65
65
  def initialize(args = {})
66
66
  super(args)
67
67
  self.media_type = args[:media_type]
68
+ self.size = args[:size]
68
69
  self.uri = args[:uri]
69
70
  self.data = args[:data]
70
71
  self.compression_algorithm = args[:compression_algorithm]
@@ -81,6 +82,21 @@ module OpenEHR
81
82
  @media_type = media_type
82
83
  end
83
84
 
85
+ # size is Integer [1..1] since RM 1.1.0 (size in bytes of the raw
86
+ # data content). Left unset (nil), it falls back to the inherited
87
+ # DvEncapsulated#size (@value.size) for backward compatibility.
88
+ def size
89
+ @size.nil? ? super : @size
90
+ end
91
+
92
+ def size=(size)
93
+ unless size.nil?
94
+ raise ArgumentError, 'size must be an Integer' unless size.is_a?(Integer)
95
+ raise ArgumentError, 'size must not be negative' if size.negative?
96
+ end
97
+ @size = size
98
+ end
99
+
84
100
  def is_external?
85
101
  !@uri.nil?
86
102
  end
@@ -30,6 +30,17 @@ module OpenEHR
30
30
  end
31
31
 
32
32
  undef magnitude=
33
+
34
+ private
35
+
36
+ # Shared guard for add/subtract on DvDate/DvTime/DvDateTime:
37
+ # DvAbsoluteQuantity#add/subtract assume magnitude=-based
38
+ # reconstruction, which DvTemporal subclasses can't support
39
+ # (see the undef above), so each subclass implements its own
40
+ # add/subtract against a DvDuration directly instead.
41
+ def validate_duration_operand(a_diff)
42
+ raise ArgumentError, 'a_diff must be a DvDuration' unless a_diff.is_a?(DvDuration)
43
+ end
33
44
  end
34
45
 
35
46
  class DvDate < DvTemporal
@@ -74,8 +85,30 @@ module OpenEHR
74
85
  week.to_s + 'W' + day.to_s + 'D')
75
86
  end
76
87
 
88
+ def add(a_diff)
89
+ validate_duration_operand(a_diff)
90
+ raise ArgumentError, 'a_diff must not have a time component' if time_component?(a_diff)
91
+
92
+ DvDate.new(:value => shift_by(a_diff).iso8601)
93
+ end
94
+
95
+ def subtract(a_diff)
96
+ add(-a_diff)
97
+ end
98
+
77
99
  private
78
100
 
101
+ def time_component?(a_diff)
102
+ [a_diff.hours, a_diff.minutes, a_diff.seconds].any? { |v| v && !v.zero? }
103
+ end
104
+
105
+ def shift_by(a_diff)
106
+ sign = a_diff.negative? ? -1 : 1
107
+ months = (((a_diff.years || 0) * MONTH_IN_YEAR) + (a_diff.months || 0)) * sign
108
+ days = (((a_diff.weeks || 0) * DAYS_IN_WEEK) + (a_diff.days || 0)) * sign
109
+ (Date.new(@year, @month, @day) >> months) + days
110
+ end
111
+
79
112
  # Returns [month_adjustment, day] for diff: when future's
80
113
  # day-of-month is behind past's, a month is borrowed from
81
114
  # future's calendar to make day non-negative.
@@ -130,6 +163,48 @@ module OpenEHR
130
163
  end
131
164
  return DvDuration.new(:value => str)
132
165
  end
166
+
167
+ def add(a_diff)
168
+ validate_duration_operand(a_diff)
169
+ raise ArgumentError, 'a_diff must not have a date component' if date_component?(a_diff)
170
+
171
+ DvTime.new(:value => shifted_time_string(a_diff))
172
+ end
173
+
174
+ def subtract(a_diff)
175
+ add(-a_diff)
176
+ end
177
+
178
+ private
179
+
180
+ def date_component?(a_diff)
181
+ [a_diff.years, a_diff.months, a_diff.weeks, a_diff.days].any? { |v| v && !v.zero? }
182
+ end
183
+
184
+ def duration_seconds(a_diff)
185
+ ((a_diff.hours || 0) * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) +
186
+ ((a_diff.minutes || 0) * SECONDS_IN_MINUTE) +
187
+ (a_diff.seconds || 0) +
188
+ (a_diff.fractional_second || 0)
189
+ end
190
+
191
+ def shifted_time_string(a_diff)
192
+ sign = a_diff.negative? ? -1 : 1
193
+ day_seconds = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
194
+ total = (magnitude + (sign * duration_seconds(a_diff))) % day_seconds
195
+ formatted_time(total) + timezone
196
+ end
197
+
198
+ def formatted_time(total_seconds)
199
+ hour = (total_seconds / (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)).to_i
200
+ remainder = total_seconds - (hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
201
+ minute = (remainder / SECONDS_IN_MINUTE).to_i
202
+ second_with_fraction = remainder - (minute * SECONDS_IN_MINUTE)
203
+ second = second_with_fraction.to_i
204
+ fractional = (second_with_fraction - second).round(6)
205
+ str = format('%02d:%02d:%02d', hour, minute, second)
206
+ fractional.zero? ? str : str + fractional.to_s[1..]
207
+ end
133
208
  end
134
209
 
135
210
  class DvDateTime < DvTemporal
@@ -204,11 +279,55 @@ module OpenEHR
204
279
  end
205
280
  # rubocop:enable Metrics/AbcSize
206
281
 
282
+ def add(a_diff)
283
+ validate_duration_operand(a_diff)
284
+
285
+ shifted_date = date_part_shifted_by(a_diff)
286
+ day_carry, wrapped_seconds = time_of_day_seconds_shifted_by(a_diff).divmod(day_seconds)
287
+ DvDateTime.new(:value => combine(shifted_date + day_carry, wrapped_seconds) + timezone)
288
+ end
289
+
290
+ def subtract(a_diff)
291
+ add(-a_diff)
292
+ end
293
+
207
294
  private
295
+
208
296
  def split_date_time(date_time)
209
297
  /^(.*)T(.*)$/ =~ date_time.as_string
210
298
  return DvDate.new(:value => $1), DvTime.new(:value => $2)
211
299
  end
300
+
301
+ def day_seconds
302
+ HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
303
+ end
304
+
305
+ def date_part_shifted_by(a_diff)
306
+ sign = a_diff.negative? ? -1 : 1
307
+ months = (((a_diff.years || 0) * MONTH_IN_YEAR) + (a_diff.months || 0)) * sign
308
+ days = (((a_diff.weeks || 0) * DAYS_IN_WEEK) + (a_diff.days || 0)) * sign
309
+ (Date.new(@year, @month, @day) >> months) + days
310
+ end
311
+
312
+ def time_of_day_seconds_shifted_by(a_diff)
313
+ sign = a_diff.negative? ? -1 : 1
314
+ own_seconds = (@hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) + (@minute * SECONDS_IN_MINUTE) +
315
+ @second + (@fractional_second || 0)
316
+ diff_seconds = ((a_diff.hours || 0) * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) +
317
+ ((a_diff.minutes || 0) * SECONDS_IN_MINUTE) + (a_diff.seconds || 0) + (a_diff.fractional_second || 0)
318
+ own_seconds + (sign * diff_seconds)
319
+ end
320
+
321
+ def combine(date, total_seconds)
322
+ hour = (total_seconds / (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)).to_i
323
+ remainder = total_seconds - (hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
324
+ minute = (remainder / SECONDS_IN_MINUTE).to_i
325
+ second_with_fraction = remainder - (minute * SECONDS_IN_MINUTE)
326
+ second = second_with_fraction.to_i
327
+ fractional = (second_with_fraction - second).round(6)
328
+ str = date.iso8601 + format('T%02d:%02d:%02d', hour, minute, second)
329
+ fractional.zero? ? str : str + fractional.to_s[1..]
330
+ end
212
331
  end
213
332
 
214
333
  class DvDuration < DvTemporal
@@ -261,6 +380,55 @@ module OpenEHR
261
380
  text = value.start_with?('-') ? value[1..-1] : "-#{value}"
262
381
  DvDuration.new(:value => text)
263
382
  end
383
+
384
+ # DV_DURATION is a DV_AMOUNT in the openEHR RM even though it
385
+ # inherits DvAbsoluteQuantity's add/subtract/diff method names
386
+ # here (see DvTemporal); those assume magnitude=-based
387
+ # reconstruction, which DvTemporal subclasses can't support,
388
+ # so +/-/multiply are implemented directly instead, going
389
+ # through magnitude (already sign-aware and nominal-month
390
+ # aware) and reconstructing via an ISO8601 string.
391
+ def +(other)
392
+ raise ArgumentError, 'type mismatch' unless is_strictly_comparable_to?(other)
393
+
394
+ self.class.from_seconds(magnitude + other.magnitude)
395
+ end
396
+
397
+ def -(other)
398
+ raise ArgumentError, 'type mismatch' unless is_strictly_comparable_to?(other)
399
+
400
+ self.class.from_seconds(magnitude - other.magnitude)
401
+ end
402
+
403
+ def multiply(factor)
404
+ raise ArgumentError, 'factor must be Numeric' unless factor.is_a?(Numeric)
405
+
406
+ self.class.from_seconds(magnitude * factor)
407
+ end
408
+ alias * multiply
409
+
410
+ def self.from_seconds(total_seconds)
411
+ sign = total_seconds.negative? ? '-' : ''
412
+ days, hours, minutes, seconds_part = duration_components(total_seconds.abs)
413
+ new(:value => "#{sign}P0Y0M0W#{days}DT#{hours}H#{minutes}M#{seconds_part}S")
414
+ end
415
+
416
+ def self.duration_components(total_seconds)
417
+ days, remainder = total_seconds.divmod(HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
418
+ hours, remainder = remainder.divmod(MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
419
+ minutes, remainder = remainder.divmod(SECONDS_IN_MINUTE)
420
+ [days, hours, minutes, seconds_with_fraction(remainder)]
421
+ end
422
+ private_class_method :duration_components
423
+
424
+ def self.seconds_with_fraction(remainder)
425
+ seconds = remainder.to_i
426
+ fractional = (remainder - seconds).round(6)
427
+ seconds += 1 if fractional >= 1.0
428
+ fractional = 0.0 if fractional >= 1.0
429
+ fractional.zero? ? seconds.to_s : "#{seconds}#{fractional.to_s[1..]}"
430
+ end
431
+ private_class_method :seconds_with_fraction
264
432
  end
265
433
  end # of DateTime
266
434
  end # of Quantity
@@ -237,23 +237,27 @@ module OpenEHR
237
237
  unless self.is_strictly_comparable_to? other
238
238
  raise ArgumentError, 'type mismatch'
239
239
  end
240
- result = self.dup
241
- result.magnitude = @magnitude + other.magnitude
242
- return result
240
+ build_arithmetic_result(magnitude + other.magnitude)
243
241
  end
244
242
 
245
243
  def -(other)
246
- negated = other.dup
247
- negated.magnitude = -other.magnitude
248
- self + negated
244
+ unless self.is_strictly_comparable_to? other
245
+ raise ArgumentError, 'type mismatch'
246
+ end
247
+ build_arithmetic_result(magnitude - other.magnitude)
249
248
  end
250
249
 
251
250
  def -@
252
- negated = self.dup
253
- negated.magnitude = -magnitude
254
- negated
251
+ build_arithmetic_result(-magnitude)
255
252
  end
256
253
 
254
+ def multiply(factor)
255
+ raise ArgumentError, 'factor must be Numeric' unless factor.is_a?(Numeric)
256
+
257
+ build_arithmetic_result(magnitude * factor)
258
+ end
259
+ alias * multiply
260
+
257
261
  def set_accuracy(accuracy, accuracy_percent)
258
262
  if accuracy_percent
259
263
  raise ArgumentError, 'accuracy invalid' unless self.class.valid_percentage(accuracy)
@@ -270,6 +274,20 @@ module OpenEHR
270
274
  def self.valid_percentage(number)
271
275
  number >= 0.0 && number <= 100.0
272
276
  end
277
+
278
+ private
279
+
280
+ # Overridable hook used by +/-/-@/multiply: reconstructs the
281
+ # result from a new magnitude via dup + magnitude=. Works for
282
+ # any subclass whose magnitude is ivar-backed (DvQuantity,
283
+ # DvCount). DvProportion's magnitude is derived from
284
+ # numerator/denominator instead, so it overrides +/-@/multiply
285
+ # directly rather than relying on this hook.
286
+ def build_arithmetic_result(new_magnitude)
287
+ result = dup
288
+ result.magnitude = new_magnitude
289
+ result
290
+ end
273
291
  end
274
292
 
275
293
  class DvQuantity < DvAmount
@@ -446,6 +464,43 @@ module OpenEHR
446
464
  return false
447
465
  end
448
466
  end
467
+
468
+ # DvProportion's magnitude is derived from numerator/denominator
469
+ # rather than ivar-backed, so it cannot use DvAmount's default
470
+ # build_arithmetic_result (magnitude=-based reconstruction) -
471
+ # +/-@/multiply are overridden here at the numerator/denominator
472
+ # level instead. Only same-denominator addition/subtraction is
473
+ # well-defined without inventing a fraction-normalization
474
+ # policy beyond what the RM specifies.
475
+ def +(other)
476
+ unless is_strictly_comparable_to?(other) && denominator == other.denominator
477
+ raise ArgumentError, 'type mismatch: proportions must share type and denominator'
478
+ end
479
+ build_arithmetic_result(numerator + other.numerator)
480
+ end
481
+
482
+ def -(other)
483
+ self + (-other)
484
+ end
485
+
486
+ def -@
487
+ build_arithmetic_result(-numerator)
488
+ end
489
+
490
+ def multiply(factor)
491
+ raise ArgumentError, 'factor must be Numeric' unless factor.is_a?(Numeric)
492
+
493
+ build_arithmetic_result(numerator * factor)
494
+ end
495
+ alias * multiply
496
+
497
+ private
498
+
499
+ def build_arithmetic_result(new_numerator)
500
+ result = dup
501
+ result.numerator = new_numerator
502
+ result
503
+ end
449
504
  end # end of DvProportion
450
505
  end # of Quantity
451
506
  end # of Data_Types
@@ -45,6 +45,8 @@ module OpenEHR
45
45
  @time_created = time_created
46
46
  end
47
47
 
48
+ # contributions is optional (0..1) since RM 1.1.0 (was mandatory
49
+ # pre-1.1.0).
48
50
  def contributions=(contributions)
49
51
  unless contributions.nil?
50
52
  contributions.each do |contrib|
@@ -52,10 +54,8 @@ module OpenEHR
52
54
  raise ArgumentError, 'contribution type should be CONTRIBUTION'
53
55
  end
54
56
  end
55
- @contributions = contributions
56
- else
57
- raise ArgumentError, 'contributions are mandatory'
58
57
  end
58
+ @contributions = contributions
59
59
  end
60
60
 
61
61
  def ehr_access=(ehr_access)
@@ -72,6 +72,8 @@ module OpenEHR
72
72
  @ehr_status = ehr_status
73
73
  end
74
74
 
75
+ # compositions is optional (0..1) since RM 1.1.0 (was mandatory
76
+ # pre-1.1.0).
75
77
  def compositions=(compositions)
76
78
  unless compositions.nil?
77
79
  compositions.each do |compo|
@@ -79,10 +81,8 @@ module OpenEHR
79
81
  raise ArgumentError, 'composition type should be VERSIONED_COMPOSITION'
80
82
  end
81
83
  end
82
- @compositions = compositions
83
- else
84
- raise ArgumentError, 'compositions are mandatory'
85
84
  end
85
+ @compositions = compositions
86
86
  end
87
87
 
88
88
  def directory=(directory)
@@ -597,6 +597,10 @@ module OpenEHR
597
597
 
598
598
  class CompositionFactory < Factory
599
599
  class << self
600
+ def create(param)
601
+ OpenEHR::RM::Composition::Composition.new(param)
602
+ end
603
+
600
604
  def create_from_json(json)
601
605
  hash = JSON.parse(json, max_nesting: false, symbolize_names: true)
602
606
  OpenEHR::RM::Composition::Composition.new(params(hash))