lutaml-model 0.8.45 → 0.8.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07e6ecf8e0047cd91862259a5a9db3b1cfb09122e752cdd17a4523d7169c89c5
4
- data.tar.gz: 5fd97454986a0d0f4bd1f13d75af01d0a692618acd9fafcc702bfaea7a73827f
3
+ metadata.gz: b2f28f3c9d10541b92778ec5ab996c7b41110bb5784b4832d08e650a758db5f8
4
+ data.tar.gz: 22b516d73a1b76acf9421226b3f00204c3dec0cc46e4e1a9f1531ee979b1f8a2
5
5
  SHA512:
6
- metadata.gz: d95d9d885d0fbc63200900751b75c62282b63970f3fe4ebb1707ff134452bd57d714b8aad22f2253b70ea9509d1456dfd6bbee2a43f3cb292de8ae87959ab617
7
- data.tar.gz: 455d7af1865469474d7f11f623dede9fbd65311dabaa30c0546a0193bdfbab4e99356b50ca6e8ffa2deb5f108ca0d5e2de9bf112e03ce607328c9ff4e6de0a8d
6
+ metadata.gz: 779f779b5b3d76947306eec00d8652c4d8ca5e41a1a5846df1b34d5b52c03cc7e6302a53a4280d921aaddc96172daed52e1476b0662b60af40ea49558cba87f5
7
+ data.tar.gz: 1e19049f5e0e8a57218bdd2d6a3366f018c37b53a773c6f660d18eb071fa7736db3521762040314097b0ab8b0d9507bd2a778a06af17719935c013639854ff82
@@ -645,3 +645,55 @@ end
645
645
  Given `<component type="guidance">`, `<component type="purpose">`,
646
646
  `<component type="guidance">` in the source, `to_xml` re-emits exactly
647
647
  that guidance, purpose, guidance sequence.
648
+
649
+
650
+ ==== Key-value formats
651
+
652
+ The same discriminator works for JSON, YAML, TOML and Hash: the wire
653
+ name is an object key, and the discriminator is a sibling key's value
654
+ inside each item.
655
+
656
+ [source,ruby]
657
+ ----
658
+ class KvRequirement < Lutaml::Model::Serializable
659
+ attribute :guidance, Component, collection: true
660
+ attribute :purpose, Component, collection: true
661
+
662
+ json do
663
+ map "component", to: :guidance,
664
+ when_attribute: { "type" => "guidance" }
665
+ map "component", to: :purpose,
666
+ when_attribute: { "type" => "purpose" }
667
+ end
668
+ end
669
+ ----
670
+
671
+ [source,json]
672
+ ----
673
+ { "component": [
674
+ { "type": "guidance", "text": "g1" },
675
+ { "type": "purpose", "text": "p1" }
676
+ ] }
677
+ ----
678
+
679
+ `guidance` holds `g1` and `purpose` holds `p1`. A bare object instead of
680
+ an array counts as one occurrence. Every semantics from the XML side
681
+ carries over: plain rules on the same key capture only unclaimed items,
682
+ `unmatched: :raise` fails the parse on an item no rule claims, and
683
+ string or symbol keys both match.
684
+
685
+ On serialization the rules merge back under the one key, each item
686
+ stamped with its rule's discriminator pair (unless the item's own
687
+ mapping already writes that key):
688
+
689
+ [source,json]
690
+ ----
691
+ { "component": [
692
+ { "type": "guidance", "text": "g1" },
693
+ { "type": "purpose", "text": "p1" }
694
+ ] }
695
+ ----
696
+
697
+ Items are grouped by rule in mapping-declaration order — object
698
+ documents carry no interleaving to preserve. `when_attribute` cannot be
699
+ combined with `with:` or `delegate:` in key-value mappings.
@@ -79,12 +79,21 @@ module Lutaml
79
79
  polymorphic_map: {},
80
80
  transform: {},
81
81
  value_map: {},
82
+ when_attribute: {},
83
+ unmatched: :drop,
82
84
  serialize: true
83
85
  )
84
86
  mapping_name = name_for_mapping(root_mappings, name)
85
87
  validate!(mapping_name, to, with, render_nil, render_empty)
88
+ validate_when_attribute!(when_attribute) unless when_attribute.empty?
89
+ validate_unmatched!(unmatched, when_attribute)
90
+ if !when_attribute.empty? && (!with.empty? || delegate)
91
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
92
+ "when_attribute cannot be combined with :with or :delegate " \
93
+ "in key-value mappings"
94
+ end
86
95
 
87
- @mappings[mapping_name] = MappingRule.new(
96
+ rule = MappingRule.new(
88
97
  mapping_name,
89
98
  to: to,
90
99
  render_nil: render_nil,
@@ -101,8 +110,23 @@ module Lutaml
101
110
  polymorphic_map: polymorphic_map,
102
111
  transform: transform,
103
112
  value_map: value_map,
113
+ when_attribute: when_attribute,
114
+ unmatched: unmatched,
104
115
  serialize: serialize,
105
116
  )
117
+ # lutaml-model#88: rules may share a wire key (when_attribute
118
+ # partitions) — store per-key arrays, like the XML mapping. A
119
+ # rule redefining the same target (`to:`) replaces its
120
+ # predecessor, so subclass and redeclaration overrides keep the
121
+ # pre-array replace semantics.
122
+ existing = @mappings[mapping_name]
123
+ if existing.nil?
124
+ @mappings[mapping_name] = [rule]
125
+ elsif (index = existing.index { |r| r.to == rule.to })
126
+ existing[index] = rule
127
+ else
128
+ existing << rule
129
+ end
106
130
  end
107
131
 
108
132
  alias map_element map
@@ -117,14 +141,14 @@ module Lutaml
117
141
  @raw_mapping = true
118
142
  validate!(Lutaml::Model::Constants::RAW_MAPPING_KEY, to, with,
119
143
  render_nil, nil)
120
- @mappings[Lutaml::Model::Constants::RAW_MAPPING_KEY] = MappingRule.new(
144
+ @mappings[Lutaml::Model::Constants::RAW_MAPPING_KEY] = [MappingRule.new(
121
145
  Lutaml::Model::Constants::RAW_MAPPING_KEY,
122
146
  to: to,
123
147
  render_nil: render_nil,
124
148
  render_default: render_default,
125
149
  with: with,
126
150
  delegate: delegate,
127
- )
151
+ )]
128
152
  end
129
153
 
130
154
  alias map_all_content map_all
@@ -153,7 +177,9 @@ module Lutaml
153
177
  return if !instance_mapping?
154
178
 
155
179
  mapping_name = name_for_mapping(nil, key_name || @instance)
156
- @mappings[mapping_name].child_mappings = @key_mapping.merge(@value_mapping)
180
+ @mappings[mapping_name].each do |rule|
181
+ rule.child_mappings = @key_mapping.merge(@value_mapping)
182
+ end
157
183
  end
158
184
 
159
185
  def name_for_mapping(root_mappings, name)
@@ -164,7 +190,7 @@ module Lutaml
164
190
 
165
191
  def mappings(register_id = nil)
166
192
  ensure_mappings_imported!(register_id) if finalized?
167
- mappings_hash(register_id).values
193
+ mappings_hash(register_id).values.flatten
168
194
  end
169
195
 
170
196
  def mappings_hash(register_id = nil)
@@ -271,7 +297,7 @@ module Lutaml
271
297
  end
272
298
 
273
299
  def find_by_name(name)
274
- @mappings.find { |m| m.name.to_s == name.to_s }
300
+ mappings.find { |m| m.name.to_s == name.to_s }
275
301
  end
276
302
 
277
303
  def polymorphic_mapping
@@ -4,6 +4,8 @@ module Lutaml
4
4
  attr_accessor :child_mappings,
5
5
  :root_mappings
6
6
 
7
+ attr_reader :unmatched
8
+
7
9
  def initialize(
8
10
  name,
9
11
  to:,
@@ -23,6 +25,8 @@ module Lutaml
23
25
  polymorphic_map: {},
24
26
  transform: {},
25
27
  value_map: {},
28
+ when_attribute: {},
29
+ unmatched: :drop,
26
30
  serialize: true
27
31
  )
28
32
  super(
@@ -42,11 +46,14 @@ module Lutaml
42
46
  polymorphic_map: polymorphic_map,
43
47
  transform: transform,
44
48
  value_map: value_map,
49
+ when_attribute: when_attribute,
45
50
  serialize: serialize,
46
51
  )
47
52
 
48
53
  @child_mappings = child_mappings
49
54
  @root_mappings = root_mappings
55
+ # lutaml-model#88: policy for occurrences claimed by no rule
56
+ @unmatched = unmatched
50
57
  end
51
58
 
52
59
  def hash_mappings
@@ -67,6 +74,8 @@ module Lutaml
67
74
  child_mappings: Lutaml::Model::Utils.deep_dup(child_mappings),
68
75
  root_mappings: Lutaml::Model::Utils.deep_dup(root_mappings),
69
76
  value_map: Lutaml::Model::Utils.deep_dup(@value_map),
77
+ when_attribute: when_attribute,
78
+ unmatched: @unmatched,
70
79
  serialize: serialize?,
71
80
  )
72
81
  end
@@ -18,8 +18,12 @@ module Lutaml
18
18
  root_and_parent_assignment(instance, options)
19
19
  mappings = extract_mappings(options, format)
20
20
 
21
- mappings.mappings(lutaml_register).each do |rule|
22
- process_mapping_rule(data, instance, format, rule, options)
21
+ rules = mappings.mappings(lutaml_register)
22
+ # lutaml-model#88: nil unless the mapping partitions a wire key
23
+ # with when_attribute rules — the common case pays one scan.
24
+ partition = kv_partition(rules)
25
+ rules.each do |rule|
26
+ process_mapping_rule(data, instance, format, rule, options, partition)
23
27
  end
24
28
 
25
29
  instance
@@ -48,11 +52,22 @@ module Lutaml
48
52
  # This maintains backward compatibility for models without transformations
49
53
  mappings = extract_mappings(options, format)
50
54
 
55
+ rules = mappings.mappings(lutaml_register)
56
+ partition = kv_partition(rules)
51
57
  hash = {}
52
- mappings.mappings(lutaml_register).each do |rule|
58
+ handled_groups = nil
59
+ rules.each do |rule|
53
60
  next unless valid_mapping?(rule, options)
54
61
 
55
- process_rule!(instance, rule, hash, format, mappings, options)
62
+ group = partition && kv_partition_group(rule, partition)
63
+ if group
64
+ next if handled_groups&.include?(group)
65
+
66
+ (handled_groups ||= []) << group
67
+ process_partition_group!(instance, group, hash, format, options)
68
+ else
69
+ process_rule!(instance, rule, hash, format, mappings, options)
70
+ end
56
71
  end
57
72
 
58
73
  hash.keys == [""] ? hash[""] : hash
@@ -263,7 +278,163 @@ format)
263
278
  options[:mappings] || mappings_for(format, lutaml_register)
264
279
  end
265
280
 
266
- def process_mapping_rule(doc, instance, format, rule, options = {})
281
+ # ---- lutaml-model#88: when_attribute for key-value formats ----
282
+
283
+ # Wire keys partitioned by when_attribute rules: key -> { rules: all
284
+ # rules on the key in declaration order, discriminators: the subset
285
+ # carrying when_attribute }. nil unless any rule uses when_attribute.
286
+ def kv_partition(rules)
287
+ partitions = nil
288
+ rules.each do |rule|
289
+ next if rule.when_attribute.empty?
290
+
291
+ partitions ||= {}
292
+ key = kv_partition_key(rule)
293
+ entry = (partitions[key] ||= { rules: [], discriminators: [] })
294
+ entry[:discriminators] << rule
295
+ end
296
+ return nil unless partitions
297
+
298
+ rules.each do |rule|
299
+ key = kv_partition_key(rule)
300
+ partitions[key][:rules] << rule if partitions.key?(key)
301
+ end
302
+ partitions
303
+ end
304
+
305
+ def kv_partition_key(rule)
306
+ name = rule.name
307
+ name = name.first if name.is_a?(Array)
308
+ name.to_s
309
+ end
310
+
311
+ # The partition entry covering any of the rule's wire names.
312
+ def kv_partition_group(rule, partition)
313
+ return nil if partition.nil?
314
+
315
+ if rule.multiple_mappings?
316
+ rule.name.each do |name|
317
+ entry = partition[name.to_s]
318
+ return entry if entry
319
+ end
320
+ nil
321
+ else
322
+ partition[rule.name.to_s]
323
+ end
324
+ end
325
+
326
+ # A value at a partitioned key, filtered for one rule: a
327
+ # discriminator rule keeps its matches; a plain rule keeps the
328
+ # occurrences no discriminator claimed (single-capture, mirroring
329
+ # the XML side).
330
+ def apply_kv_when_attribute(value, rule, entry, attr = nil)
331
+ if rule.when_attribute.empty?
332
+ return kv_partition_value(value, attr) do |item|
333
+ entry[:discriminators].none? { |s| kv_item_matches?(item, s) }
334
+ end
335
+ end
336
+
337
+ plain_sibling = entry[:rules].any? { |r| r.when_attribute.empty? }
338
+ if rule.unmatched == :raise && !plain_sibling
339
+ check_unclaimed_kv_items!(value, rule, entry)
340
+ end
341
+ kv_partition_value(value, attr) { |item| kv_item_matches?(item, rule) }
342
+ end
343
+
344
+ def kv_item_matches?(item, rule)
345
+ return false unless item.is_a?(::Hash)
346
+
347
+ rule.when_attribute.all? do |name, expected|
348
+ actual = Lutaml::Model::Utils.fetch_str_or_sym(item, name.to_s)
349
+ !actual.nil? && actual.to_s == expected.to_s
350
+ end
351
+ end
352
+
353
+ # select/reject over the occurrence shape: an Array filters per
354
+ # item; a single occurrence is kept or dropped as a whole; nil
355
+ # passes through. A dropped occurrence existed on the wire, so a
356
+ # collection attribute reads it as [] rather than absent. A
357
+ # non-hash item satisfies no discriminator, so plain rules keep it
358
+ # and discriminator rules drop it.
359
+ def kv_partition_value(value, attr = nil, &block)
360
+ case value
361
+ when ::Array
362
+ value.select(&block)
363
+ when nil
364
+ nil
365
+ else
366
+ if yield(value)
367
+ value
368
+ else
369
+ attr&.collection? ? [] : nil
370
+ end
371
+ end
372
+ end
373
+
374
+ # lutaml-model#88: fail closed for `unmatched: :raise` — an item
375
+ # no rule on the key claims is exactly the data the default
376
+ # policy silently drops.
377
+ def check_unclaimed_kv_items!(value, rule, entry)
378
+ items = value.is_a?(::Array) ? value : [value]
379
+ tested = entry[:discriminators].flat_map { |s| s.when_attribute.keys }
380
+ .uniq.map(&:to_s)
381
+ items.each do |item|
382
+ next if entry[:discriminators].any? { |s| kv_item_matches?(item, s) }
383
+
384
+ values = tested.filter_map do |name|
385
+ v = item.is_a?(::Hash) &&
386
+ Lutaml::Model::Utils.fetch_str_or_sym(item, name)
387
+ "#{name}=#{v.inspect}" if v
388
+ end
389
+ raise ::Lutaml::Model::UnknownDiscriminatorError,
390
+ "Item at <#{kv_partition_key(rule)}> (#{values.join(', ')}) " \
391
+ "is claimed by no rule: it matches no when_attribute " \
392
+ "discriminator and no plain rule shares the key. Cover the " \
393
+ "value, add a plain rule, or opt out with unmatched: :drop"
394
+ end
395
+ nil
396
+ end
397
+
398
+ # Serialize a partitioned key as one wire value: every rule on the
399
+ # key contributes its items in declaration order, each stamped with
400
+ # its rule's discriminator pairs (unless the item already carries
401
+ # the key — the value's own mapping wins).
402
+ def process_partition_group!(instance, group, hash, format, options)
403
+ wire = rule_from_name(group[:rules].first)
404
+ items = []
405
+ present = false
406
+ group[:rules].each do |rule|
407
+ next unless valid_mapping?(rule, options)
408
+
409
+ scratch = {}
410
+ process_mapping_for_instance(instance, scratch, format, rule, options)
411
+ value = scratch[rule_from_name(rule)]
412
+ next if value.nil?
413
+
414
+ present = true
415
+ Array(value).each do |item|
416
+ items << kv_stamp_discriminator(item, rule)
417
+ end
418
+ end
419
+ return unless present
420
+
421
+ hash[wire] = items
422
+ end
423
+
424
+ def kv_stamp_discriminator(item, rule)
425
+ return item unless item.is_a?(::Hash)
426
+
427
+ missing = {}
428
+ rule.when_attribute.each do |name, expected|
429
+ unless Lutaml::Model::Utils.string_or_symbol_key?(item, name)
430
+ missing[name.to_s] = expected.to_s
431
+ end
432
+ end
433
+ missing.empty? ? item : item.merge(missing)
434
+ end
435
+
436
+ def process_mapping_rule(doc, instance, format, rule, options = {},
437
+ partition = nil)
267
438
  attr = attribute_for_rule(rule)
268
439
  return if attr&.derived?
269
440
 
@@ -271,7 +442,11 @@ format)
271
442
  rule, attr
272
443
  )
273
444
 
274
- if (plan = kv_rule_plan(format, rule, attr)) &&
445
+ # lutaml-model#88: discriminator rules (and plain rules on a
446
+ # partitioned key) must filter the raw value, so they keep the
447
+ # interpretive path.
448
+ partitioned = partition && kv_partition_group(rule, partition)
449
+ if partitioned.nil? && (plan = kv_rule_plan(format, rule, attr)) &&
275
450
  (value = kv_fast_extract(doc, plan))
276
451
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
277
452
  attributes, self, options[:context])
@@ -280,6 +455,9 @@ format)
280
455
 
281
456
  value = rule_value_extractor_class.call(rule, doc, format, attr,
282
457
  lutaml_register, options, instance)
458
+ if partitioned && !Lutaml::Model::Utils.uninitialized?(value)
459
+ value = apply_kv_when_attribute(value, rule, partitioned, attr)
460
+ end
283
461
  value = apply_value_map(value, rule.value_map(:from, options), attr)
284
462
 
285
463
  if rule.has_custom_method_for_deserialization?
@@ -328,7 +506,8 @@ format)
328
506
  return nil if rule.delegate || rule.raw_mapping? || rule.root_mapping? ||
329
507
  rule.hash_mappings || rule.child_mappings ||
330
508
  rule.has_custom_method_for_serialization? ||
331
- rule.multiple_mappings? || polymorphic_rule?(rule) ||
509
+ rule.when_attribute? || rule.multiple_mappings? ||
510
+ polymorphic_rule?(rule) ||
332
511
  (rule.transform.is_a?(Hash) && !rule.transform.empty?) ||
333
512
  rule.transform.is_a?(Class) || attr.nil? || attr.derived? ||
334
513
  attr.union? || attr.polymorphic? || attr.custom_collection? ||
@@ -231,6 +231,8 @@ transformation_factory:)
231
231
  delegate: delegate,
232
232
  root_mappings: mapping_rule.root_mappings,
233
233
  serialize: mapping_rule.serialize?,
234
+ when_attribute: mapping_rule.when_attribute,
235
+ unmatched: mapping_rule.unmatched,
234
236
  )
235
237
  end
236
238
 
@@ -118,6 +118,37 @@ register = self.register)
118
118
  end
119
119
  end
120
120
 
121
+ # lutaml-model#88: rules sharing a wire key through when_attribute
122
+ # must serialize as ONE value — group them here, before freeze.
123
+ # Rules with custom `to` methods or delegates keep their own path;
124
+ # a key mixing them with discriminator rules is not a partition.
125
+ def after_compile
126
+ partitioned_names = nil
127
+ compiled_rules.each do |rule|
128
+ pairs = rule.option(:when_attribute)
129
+ next if pairs.nil? || pairs.empty?
130
+
131
+ (partitioned_names ||= {})[rule.serialized_name] = true
132
+ end
133
+ return unless partitioned_names
134
+
135
+ groups = {}
136
+ by_rule = {}.compare_by_identity
137
+ compiled_rules.each do |rule|
138
+ name = rule.serialized_name
139
+ next unless partitioned_names[name]
140
+ next if (rule.custom_methods || {}).key?(:to) || rule.option(:delegate)
141
+
142
+ entry = (groups[name] ||= [])
143
+ entry << rule
144
+ by_rule[rule] = entry
145
+ end
146
+ groups.each_value(&:freeze)
147
+
148
+ @partition_groups = groups.freeze
149
+ @partitioned_rules = by_rule
150
+ end
151
+
121
152
  private
122
153
 
123
154
  # Get the register ID, handling both Symbol and Register objects
@@ -180,12 +211,21 @@ register = self.register)
180
211
  # Instead, we create an anonymous root that holds all attributes
181
212
  root = Lutaml::KeyValue::DataModel::Element.new("__root__")
182
213
 
183
- # Apply each compiled rule (with filtering support)
214
+ # Apply each compiled rule (with filtering support). A
215
+ # when_attribute partition applies once for its whole key.
216
+ handled_groups = nil
184
217
  compiled_rules.each do |rule|
185
218
  # Check if this rule should be applied based on only/except options
186
219
  next unless valid_mapping?(rule, options)
187
220
 
188
- apply_rule(root, rule, model_instance, options)
221
+ if @partitioned_rules && (group = @partitioned_rules[rule])
222
+ next if handled_groups&.include?(group)
223
+
224
+ (handled_groups ||= []) << group
225
+ apply_partition_group(root, group, model_instance, options)
226
+ else
227
+ apply_rule(root, rule, model_instance, options)
228
+ end
189
229
  end
190
230
 
191
231
  if ENV["DEBUG_KEYED_COLLECTION"]
@@ -202,6 +242,77 @@ register = self.register)
202
242
 
203
243
  private
204
244
 
245
+ # The rule's value on the instance, initializing an absent
246
+ # delegate target like apply_rule does.
247
+ def extract_rule_value(rule, model_instance)
248
+ delegate = rule.option(:delegate)
249
+ unless delegate
250
+ return model_instance.public_send(rule.attribute_name)
251
+ end
252
+
253
+ delegated_object = model_instance.public_send(delegate)
254
+ if delegated_object.nil? ||
255
+ Lutaml::Model::Utils.uninitialized?(delegated_object)
256
+ delegate_attr = model_class.attributes(register_id)&.[](delegate)
257
+ if delegate_attr
258
+ delegated_object = delegate_attr.type(register_id).new
259
+ model_instance.public_send(:"#{delegate}=", delegated_object)
260
+ end
261
+ end
262
+ delegated_object&.public_send(rule.attribute_name)
263
+ end
264
+
265
+ # Serialize one when_attribute partition: every rule on the key
266
+ # contributes its items in declaration order, each stamped with
267
+ # its rule's discriminator pairs (unless the item's own mapping
268
+ # already wrote the key). The wire value is an array whenever any
269
+ # rule had a present value.
270
+ def apply_partition_group(parent, group, model_instance, options)
271
+ items = []
272
+ present = false
273
+ group.each do |rule|
274
+ next unless valid_mapping?(rule, options)
275
+
276
+ value = extract_rule_value(rule, model_instance)
277
+ next if should_skip_value?(value, rule, model_instance,
278
+ rule.option(:delegate))
279
+
280
+ value = rule.transform_value(value, :export) if rule.value_transformer
281
+
282
+ present = true
283
+ Array(value).each do |item|
284
+ child = create_value_for_item(rule, item, options)
285
+ next if child.nil?
286
+
287
+ items << stamp_discriminator(child, rule)
288
+ end
289
+ end
290
+ return unless present
291
+
292
+ element = Lutaml::KeyValue::DataModel::Element.new(
293
+ group.first.serialized_name,
294
+ )
295
+ if items.empty?
296
+ element.value = []
297
+ else
298
+ items.each { |item| element.add_child(item) }
299
+ end
300
+ parent.add_child(element)
301
+ end
302
+
303
+ def stamp_discriminator(item, rule)
304
+ pairs = rule.option(:when_attribute)
305
+ return item if pairs.nil? || pairs.empty? || !item.is_a?(::Hash)
306
+
307
+ missing = {}
308
+ pairs.each do |name, expected|
309
+ unless Lutaml::Model::Utils.string_or_symbol_key?(item, name)
310
+ missing[name.to_s] = expected.to_s
311
+ end
312
+ end
313
+ missing.empty? ? item : item.merge(missing)
314
+ end
315
+
205
316
  # Apply a single transformation rule
206
317
  #
207
318
  # @param parent [Lutaml::KeyValue::DataModel::Element] Parent element
@@ -106,6 +106,36 @@ module Lutaml
106
106
 
107
107
  attr_accessor :importable_mappings
108
108
 
109
+ # lutaml-model#88: attribute-value discriminator. Keys are wire
110
+ # names (an XML attribute, or an object key in key-value formats),
111
+ # values the expected string value. Shared by the XML and KV DSLs.
112
+ def validate_when_attribute!(when_attribute)
113
+ when_attribute.each do |k, v|
114
+ next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
115
+ (v.is_a?(::String) || v.is_a?(::Symbol))
116
+
117
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
118
+ "when_attribute expects string/symbol attribute names " \
119
+ "mapped to string/symbol values, got " \
120
+ "#{k.inspect} => #{v.inspect}"
121
+ end
122
+ end
123
+
124
+ # lutaml-model#88: what a parse does with an occurrence that no
125
+ # rule on the wire name claims — :drop it (default) or :raise
126
+ # UnknownDiscriminatorError. Only meaningful on discriminator rules.
127
+ def validate_unmatched!(unmatched, when_attribute)
128
+ unless %i[drop raise].include?(unmatched)
129
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
130
+ "unmatched expects :drop or :raise, got #{unmatched.inspect}"
131
+ end
132
+
133
+ return unless when_attribute.empty? && unmatched != :drop
134
+
135
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
136
+ "unmatched only applies to rules declared with when_attribute"
137
+ end
138
+
109
139
  def register(register_id = nil)
110
140
  register_id ||= Lutaml::Model::Config.default_register
111
141
  Lutaml::Model::GlobalRegister.lookup(register_id)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.45"
5
+ VERSION = "0.8.46"
6
6
  end
7
7
  end
@@ -614,35 +614,6 @@ module Lutaml
614
614
  end
615
615
  end
616
616
 
617
- # lutaml-model#88: attribute-value discriminator. Keys are wire
618
- # attribute names, values the expected string value.
619
- def validate_when_attribute!(when_attribute)
620
- when_attribute.each do |k, v|
621
- next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
622
- (v.is_a?(::String) || v.is_a?(::Symbol))
623
-
624
- raise Lutaml::Model::IncorrectMappingArgumentsError,
625
- "when_attribute expects string/symbol attribute names " \
626
- "mapped to string/symbol values, got " \
627
- "#{k.inspect} => #{v.inspect}"
628
- end
629
- end
630
-
631
- # lutaml-model#88: what a parse does with an occurrence that no
632
- # rule on the wire name claims — :drop it (default) or :raise
633
- # UnknownDiscriminatorError. Only meaningful on discriminator rules.
634
- def validate_unmatched!(unmatched, when_attribute)
635
- unless %i[drop raise].include?(unmatched)
636
- raise Lutaml::Model::IncorrectMappingArgumentsError,
637
- "unmatched expects :drop or :raise, got #{unmatched.inspect}"
638
- end
639
-
640
- return unless when_attribute.empty? && unmatched != :drop
641
-
642
- raise Lutaml::Model::IncorrectMappingArgumentsError,
643
- "unmatched only applies to rules declared with when_attribute"
644
- end
645
-
646
617
  def map_attribute(
647
618
  name,
648
619
  to: nil,
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: `when_attribute` partitioning for key-value formats.
6
+ # The discriminator is an object key: items of the array at a shared wire
7
+ # key are claimed by the rule whose pairs their key values satisfy.
8
+ # Serialization merges every rule's items back under the one key, each
9
+ # item stamped with its rule's discriminator pairs.
10
+ class KvWhenComponent < Lutaml::Model::Serializable
11
+ attribute :text, :string
12
+
13
+ json do
14
+ map "text", to: :text
15
+ end
16
+
17
+ yaml do
18
+ map "text", to: :text
19
+ end
20
+
21
+ toml do
22
+ map "text", to: :text
23
+ end
24
+
25
+ hsh do
26
+ map "text", to: :text
27
+ end
28
+ end
29
+
30
+ class KvWhenRequirement < Lutaml::Model::Serializable
31
+ attribute :guidance, KvWhenComponent, collection: true
32
+ attribute :purpose, KvWhenComponent, collection: true
33
+
34
+ json do
35
+ map "component", to: :guidance,
36
+ when_attribute: { "type" => "guidance" }
37
+ map "component", to: :purpose,
38
+ when_attribute: { "type" => "purpose" }
39
+ end
40
+
41
+ yaml do
42
+ map "component", to: :guidance,
43
+ when_attribute: { "type" => "guidance" }
44
+ map "component", to: :purpose,
45
+ when_attribute: { "type" => "purpose" }
46
+ end
47
+
48
+ hsh do
49
+ map "component", to: :guidance,
50
+ when_attribute: { "type" => "guidance" }
51
+ map "component", to: :purpose,
52
+ when_attribute: { "type" => "purpose" }
53
+ end
54
+ end
55
+
56
+ RSpec.describe "when_attribute for key-value formats" do
57
+ before do
58
+ stub_const("KvWhen::Component", KvWhenComponent)
59
+ stub_const("KvWhen::Requirement", KvWhenRequirement)
60
+ end
61
+
62
+ let(:payload) do
63
+ {
64
+ "component" => [
65
+ { "type" => "guidance", "text" => "g1" },
66
+ { "type" => "purpose", "text" => "p1" },
67
+ { "type" => "guidance", "text" => "g2" },
68
+ ],
69
+ }
70
+ end
71
+
72
+ it "partitions json by the discriminator key" do
73
+ req = KvWhenRequirement.from_json(payload.to_json)
74
+
75
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
76
+ expect(req.purpose.map(&:text)).to eq(["p1"])
77
+ end
78
+
79
+ it "partitions yaml arrays of mappings" do
80
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
81
+
82
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
83
+ expect(req.purpose.map(&:text)).to eq(["p1"])
84
+ end
85
+
86
+ it "partitions hashes passed directly with symbol keys" do
87
+ req = KvWhenRequirement.from_hash(
88
+ component: [
89
+ { type: "guidance", text: "g1" },
90
+ { type: "purpose", text: "p1" },
91
+ ],
92
+ )
93
+
94
+ expect(req.guidance.map(&:text)).to eq(["g1"])
95
+ expect(req.purpose.map(&:text)).to eq(["p1"])
96
+ end
97
+
98
+ it "keeps a single object occurrence as one item" do
99
+ req = KvWhenRequirement.from_hash(
100
+ "component" => { "type" => "purpose", "text" => "p1" },
101
+ )
102
+
103
+ expect(req.purpose.map(&:text)).to eq(["p1"])
104
+ expect(req.guidance).to be_empty
105
+ end
106
+
107
+ it "serializes one merged array with the discriminators stamped" do
108
+ req = KvWhenRequirement.from_json(payload.to_json)
109
+ out = req.to_json
110
+
111
+ items = JSON.parse(out)["component"]
112
+ expect(items).to eq([
113
+ { "type" => "guidance", "text" => "g1" },
114
+ { "type" => "guidance", "text" => "g2" },
115
+ { "type" => "purpose", "text" => "p1" },
116
+ ])
117
+ end
118
+
119
+ it "round-trips through yaml" do
120
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
121
+ again = KvWhenRequirement.from_yaml(req.to_yaml)
122
+
123
+ expect(again.guidance.map(&:text)).to eq(%w[g1 g2])
124
+ expect(again.purpose.map(&:text)).to eq(["p1"])
125
+ end
126
+
127
+ it "does not stamp over a key the item's own mapping writes" do
128
+ holder = Class.new(Lutaml::Model::Serializable) do
129
+ attribute :items, KvWhenComponent, collection: true
130
+
131
+ json do
132
+ map "component", to: :items,
133
+ when_attribute: { "type" => "guidance" }
134
+ end
135
+
136
+ hsh do
137
+ map "component", to: :items,
138
+ when_attribute: { "type" => "guidance" }
139
+ end
140
+ end
141
+ stub_const("KvWhen::Holder", holder)
142
+
143
+ out = holder.from_hash(
144
+ "component" => [{ "type" => "guidance", "text" => "g" }],
145
+ ).to_json
146
+ expect(JSON.parse(out)["component"]).to eq(
147
+ [{ "type" => "guidance", "text" => "g" }],
148
+ )
149
+ end
150
+
151
+ describe "plain rule complement" do
152
+ let(:holder) do
153
+ Class.new(Lutaml::Model::Serializable) do
154
+ attribute :special, KvWhenComponent, collection: true
155
+ attribute :plain, KvWhenComponent, collection: true
156
+
157
+ json do
158
+ map "item", to: :special, when_attribute: { "kind" => "special" }
159
+ map "item", to: :plain
160
+ end
161
+
162
+ hsh do
163
+ map "item", to: :special, when_attribute: { "kind" => "special" }
164
+ map "item", to: :plain
165
+ end
166
+ end
167
+ end
168
+
169
+ it "captures occurrences no discriminator claimed exactly once" do
170
+ stub_const("KvWhen::Both", holder)
171
+ doc = holder.from_hash(
172
+ "item" => [
173
+ { "kind" => "special", "text" => "b" },
174
+ { "text" => "a" },
175
+ { "kind" => "other", "text" => "x" },
176
+ ],
177
+ )
178
+
179
+ expect(doc.special.map(&:text)).to eq(["b"])
180
+ expect(doc.plain.map(&:text)).to eq(%w[a x])
181
+ end
182
+
183
+ it "merges plain and stamped items into the one key" do
184
+ stub_const("KvWhen::Both", holder)
185
+ doc = holder.from_hash(
186
+ "item" => [
187
+ { "kind" => "special", "text" => "b" },
188
+ { "text" => "a" },
189
+ ],
190
+ )
191
+ items = JSON.parse(doc.to_json)["item"]
192
+
193
+ expect(items).to eq([
194
+ { "kind" => "special", "text" => "b" },
195
+ { "text" => "a" },
196
+ ])
197
+ end
198
+ end
199
+
200
+ describe "unmatched policy" do
201
+ let(:strict) do
202
+ Class.new(Lutaml::Model::Serializable) do
203
+ attribute :guidance, KvWhenComponent, collection: true
204
+
205
+ json do
206
+ map "component", to: :guidance,
207
+ when_attribute: { "type" => "guidance" },
208
+ unmatched: :raise
209
+ end
210
+ end
211
+ end
212
+
213
+ it "raises on an unclaimed discriminator value" do
214
+ stub_const("KvWhen::Strict", strict)
215
+
216
+ expect do
217
+ strict.from_json({ "component" => [
218
+ { "type" => "guidance", "text" => "g" },
219
+ { "type" => "nope", "text" => "x" },
220
+ ] }.to_json)
221
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="nope"/)
222
+ end
223
+
224
+ it "raises on an item without the discriminator key" do
225
+ stub_const("KvWhen::Strict", strict)
226
+
227
+ expect do
228
+ strict.from_json({ "component" => [{ "text" => "x" }] }.to_json)
229
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /<component>/)
230
+ end
231
+
232
+ it "drops unclaimed occurrences by default" do
233
+ lenient = Class.new(Lutaml::Model::Serializable) do
234
+ attribute :guidance, KvWhenComponent, collection: true
235
+
236
+ json do
237
+ map "component", to: :guidance,
238
+ when_attribute: { "type" => "guidance" }
239
+ end
240
+ end
241
+ stub_const("KvWhen::Lenient", lenient)
242
+
243
+ doc = lenient.from_json({ "component" => [
244
+ { "type" => "guidance", "text" => "g" },
245
+ { "type" => "other", "text" => "x" },
246
+ ] }.to_json)
247
+ expect(doc.guidance.map(&:text)).to eq(["g"])
248
+ end
249
+ end
250
+
251
+ describe "toml arrays of tables" do
252
+ let(:toml_holder) do
253
+ Class.new(Lutaml::Model::Serializable) do
254
+ attribute :guidance, KvWhenComponent, collection: true
255
+ attribute :purpose, KvWhenComponent, collection: true
256
+
257
+ toml do
258
+ map "component", to: :guidance,
259
+ when_attribute: { "type" => "guidance" }
260
+ map "component", to: :purpose,
261
+ when_attribute: { "type" => "purpose" }
262
+ end
263
+ end
264
+ end
265
+
266
+ it "partitions and round-trips" do
267
+ stub_const("KvWhen::TomlHolder", toml_holder)
268
+ src = <<~TOML
269
+ [[component]]
270
+ type = "guidance"
271
+ text = "g1"
272
+
273
+ [[component]]
274
+ type = "purpose"
275
+ text = "p1"
276
+ TOML
277
+
278
+ doc = toml_holder.from_toml(src)
279
+ expect(doc.guidance.map(&:text)).to eq(["g1"])
280
+ expect(doc.purpose.map(&:text)).to eq(["p1"])
281
+
282
+ again = toml_holder.from_toml(doc.to_toml)
283
+ expect(again.guidance.map(&:text)).to eq(["g1"])
284
+ expect(again.purpose.map(&:text)).to eq(["p1"])
285
+ end
286
+ end
287
+
288
+ describe "DSL validation" do
289
+ it "rejects non-string/symbol pairs" do
290
+ expect do
291
+ Class.new(Lutaml::Model::Serializable) do
292
+ attribute :x, :string
293
+
294
+ json do
295
+ map "x", to: :x, when_attribute: { "type" => 42 }
296
+ end
297
+ end
298
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
299
+ end
300
+
301
+ it "rejects unmatched without when_attribute" do
302
+ expect do
303
+ Class.new(Lutaml::Model::Serializable) do
304
+ attribute :x, :string
305
+
306
+ json do
307
+ map "x", to: :x, unmatched: :raise
308
+ end
309
+ end
310
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
311
+ end
312
+
313
+ it "rejects unknown policy values" do
314
+ expect do
315
+ Class.new(Lutaml::Model::Serializable) do
316
+ attribute :x, :string
317
+
318
+ json do
319
+ map "x", to: :x, when_attribute: { "type" => "a" },
320
+ unmatched: :explode
321
+ end
322
+ end
323
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /unmatched/)
324
+ end
325
+
326
+ it "rejects when_attribute combined with custom methods" do
327
+ expect do
328
+ Class.new(Lutaml::Model::Serializable) do
329
+ attribute :x, :string
330
+
331
+ json do
332
+ map "x", to: :x, when_attribute: { "type" => "a" },
333
+ with: { to: :x_to, from: :x_from }
334
+ end
335
+ end
336
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
337
+ end
338
+
339
+ it "survives deep_dup of mappings" do
340
+ rule = KvWhenRequirement.mappings_for(:json).mappings.find do |r|
341
+ r.to == :guidance
342
+ end
343
+
344
+ expect(rule.when_attribute).to eq("type" => "guidance")
345
+ expect(rule.deep_dup.when_attribute).to eq("type" => "guidance")
346
+ end
347
+ end
348
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.45
4
+ version: 0.8.46
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1688,6 +1688,7 @@ files:
1688
1688
  - spec/lutaml/key_value/transformation/rule_compiler_spec.rb
1689
1689
  - spec/lutaml/key_value/transformation/value_serializer_spec.rb
1690
1690
  - spec/lutaml/key_value/transformation_spec.rb
1691
+ - spec/lutaml/key_value/when_attribute_spec.rb
1691
1692
  - spec/lutaml/model/absent_value_contracts_spec.rb
1692
1693
  - spec/lutaml/model/adapter_resolver_configured_spec.rb
1693
1694
  - spec/lutaml/model/attribute_apply_value_map_spec.rb