lutaml-model 0.8.45 → 0.8.47

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: c4ba22dbb3a53b5ba0cdb11840f7940507da76da3d3b7b3ccd64e52f597bdfe1
4
+ data.tar.gz: '09d45f38541abb51224c9af9a4aff11d3207a99c4c9f79206cc0a6041caa382e'
5
5
  SHA512:
6
- metadata.gz: d95d9d885d0fbc63200900751b75c62282b63970f3fe4ebb1707ff134452bd57d714b8aad22f2253b70ea9509d1456dfd6bbee2a43f3cb292de8ae87959ab617
7
- data.tar.gz: 455d7af1865469474d7f11f623dede9fbd65311dabaa30c0546a0193bdfbab4e99356b50ca6e8ffa2deb5f108ca0d5e2de9bf112e03ce607328c9ff4e6de0a8d
6
+ metadata.gz: 2a9fa0ec81775e5b928dbeb8d75b8b812d8e00c076176c1f6367e024d0bd85fd10ea27b3361160211dc47d275edff90b6d569e2ebf87b63a2f8bbba1ebf12024
7
+ data.tar.gz: '089b06566a9ec168c0491b7c584e95e68dc0bcb2c219b8932062aa3b33c3e5d696445e26782107c292c405cdead641ba33ad6571ac31f59403b2ac90d5f9a71d'
@@ -566,7 +566,8 @@ end
566
566
  === Attribute-value dispatch on a shared element name
567
567
 
568
568
  When several distinct attributes share one wire element, selected by a
569
- sibling attribute's value, declare the discriminator with `when_attribute:`
569
+ sibling attribute's value, declare the discriminator key with
570
+ `when_attribute:` and map each value to its attribute:
570
571
 
571
572
  [source,ruby]
572
573
  ----
@@ -576,14 +577,30 @@ class Requirement < Lutaml::Model::Serializable
576
577
 
577
578
  xml do
578
579
  element "requirement"
579
- map_element "component", to: :guidance,
580
- when_attribute: { "type" => "guidance" }
581
- map_element "component", to: :purpose,
582
- when_attribute: { "type" => "purpose" }
580
+ map_element "component", when_attribute: "type",
581
+ to: { "guidance" => :guidance,
582
+ "purpose" => :purpose }
583
583
  end
584
584
  end
585
585
  ----
586
586
 
587
+ That one declaration expands, at DSL time, to one rule per value —
588
+ identical to writing each rule by hand:
589
+
590
+ [source,ruby]
591
+ ----
592
+ xml do
593
+ element "requirement"
594
+ map_element "component", to: :guidance,
595
+ when_attribute: { "type" => "guidance" }
596
+ map_element "component", to: :purpose,
597
+ when_attribute: { "type" => "purpose" }
598
+ end
599
+ ----
600
+
601
+ Use the per-rule form when a partition is partial (only some values
602
+ claimed) or a rule needs several pairs — multiple pairs mean AND.
603
+
587
604
  Each rule hydrates only from the `<component>` occurrences whose `type`
588
605
  attribute carries the expected value, in document order; uncovered values
589
606
  are ignored by all discriminator rules. On serialization the discriminator
@@ -607,8 +624,8 @@ everything left, so the strict policy never fires when one is present.
607
624
  ----
608
625
  xml do
609
626
  element "requirement"
610
- map_element "component", to: :guidance,
611
- when_attribute: { "type" => "guidance" },
627
+ map_element "component", when_attribute: "type",
628
+ to: { "guidance" => :guidance },
612
629
  unmatched: :raise
613
630
  end
614
631
  ----
@@ -635,13 +652,62 @@ discriminator it satisfies.
635
652
  xml do
636
653
  element "requirement"
637
654
  ordered
638
- map_element "component", to: :guidance,
639
- when_attribute: { "type" => "guidance" }
640
- map_element "component", to: :purpose,
641
- when_attribute: { "type" => "purpose" }
655
+ map_element "component", when_attribute: "type",
656
+ to: { "guidance" => :guidance,
657
+ "purpose" => :purpose }
642
658
  end
643
659
  ----
644
660
 
645
661
  Given `<component type="guidance">`, `<component type="purpose">`,
646
662
  `<component type="guidance">` in the source, `to_xml` re-emits exactly
647
663
  that guidance, purpose, guidance sequence.
664
+
665
+
666
+ ==== Key-value formats
667
+
668
+ The same discriminator works for JSON, YAML, TOML and Hash: the wire
669
+ name is an object key, and the discriminator is a sibling key's value
670
+ inside each item.
671
+
672
+ [source,ruby]
673
+ ----
674
+ class KvRequirement < Lutaml::Model::Serializable
675
+ attribute :guidance, Component, collection: true
676
+ attribute :purpose, Component, collection: true
677
+
678
+ json do
679
+ map "component", when_attribute: "type",
680
+ to: { "guidance" => :guidance, "purpose" => :purpose }
681
+ end
682
+ end
683
+ ----
684
+
685
+ [source,json]
686
+ ----
687
+ { "component": [
688
+ { "type": "guidance", "text": "g1" },
689
+ { "type": "purpose", "text": "p1" }
690
+ ] }
691
+ ----
692
+
693
+ `guidance` holds `g1` and `purpose` holds `p1`. A bare object instead of
694
+ an array counts as one occurrence. Every semantics from the XML side
695
+ carries over: plain rules on the same key capture only unclaimed items,
696
+ `unmatched: :raise` fails the parse on an item no rule claims, and
697
+ string or symbol keys both match.
698
+
699
+ On serialization the rules merge back under the one key, each item
700
+ stamped with its rule's discriminator pair (unless the item's own
701
+ mapping already writes that key):
702
+
703
+ [source,json]
704
+ ----
705
+ { "component": [
706
+ { "type": "guidance", "text": "g1" },
707
+ { "type": "purpose", "text": "p1" }
708
+ ] }
709
+ ----
710
+
711
+ Items are grouped by rule in mapping-declaration order — object
712
+ documents carry no interleaving to preserve. `when_attribute` cannot be
713
+ combined with `with:` or `delegate:` in key-value mappings.
@@ -79,12 +79,54 @@ 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)
86
88
 
87
- @mappings[mapping_name] = MappingRule.new(
89
+ # lutaml-model#88: grouped form — `when_attribute: "type"` plus
90
+ # `to: { value => attribute }` expands to one rule per value at
91
+ # DSL time; the compiled rules are identical to the per-rule
92
+ # form, so parse/serialize behavior and speed are unchanged.
93
+ if when_attribute.is_a?(::String) || when_attribute.is_a?(::Symbol)
94
+ when_attribute_group(when_attribute, to).each do |value, target|
95
+ map(
96
+ name,
97
+ to: target,
98
+ render_nil: render_nil,
99
+ render_default: render_default,
100
+ render_empty: render_empty,
101
+ treat_nil: treat_nil,
102
+ treat_empty: treat_empty,
103
+ treat_omitted: treat_omitted,
104
+ with: with,
105
+ delegate: delegate,
106
+ child_mappings: child_mappings,
107
+ root_mappings: root_mappings,
108
+ polymorphic: polymorphic,
109
+ polymorphic_map: polymorphic_map,
110
+ transform: transform,
111
+ value_map: value_map,
112
+ when_attribute: { when_attribute.to_s => value.to_s },
113
+ unmatched: unmatched,
114
+ serialize: serialize,
115
+ )
116
+ end
117
+ return
118
+ end
119
+ reject_ambiguous_when_attribute!(when_attribute, to)
120
+
121
+ validate_when_attribute!(when_attribute) unless when_attribute.empty?
122
+ validate_unmatched!(unmatched, when_attribute)
123
+ if !when_attribute.empty? && (!with.empty? || delegate)
124
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
125
+ "when_attribute cannot be combined with :with or :delegate " \
126
+ "in key-value mappings"
127
+ end
128
+
129
+ rule = MappingRule.new(
88
130
  mapping_name,
89
131
  to: to,
90
132
  render_nil: render_nil,
@@ -101,8 +143,23 @@ module Lutaml
101
143
  polymorphic_map: polymorphic_map,
102
144
  transform: transform,
103
145
  value_map: value_map,
146
+ when_attribute: when_attribute,
147
+ unmatched: unmatched,
104
148
  serialize: serialize,
105
149
  )
150
+ # lutaml-model#88: rules may share a wire key (when_attribute
151
+ # partitions) — store per-key arrays, like the XML mapping. A
152
+ # rule redefining the same target (`to:`) replaces its
153
+ # predecessor, so subclass and redeclaration overrides keep the
154
+ # pre-array replace semantics.
155
+ existing = @mappings[mapping_name]
156
+ if existing.nil?
157
+ @mappings[mapping_name] = [rule]
158
+ elsif (index = existing.index { |r| r.to == rule.to })
159
+ existing[index] = rule
160
+ else
161
+ existing << rule
162
+ end
106
163
  end
107
164
 
108
165
  alias map_element map
@@ -117,14 +174,14 @@ module Lutaml
117
174
  @raw_mapping = true
118
175
  validate!(Lutaml::Model::Constants::RAW_MAPPING_KEY, to, with,
119
176
  render_nil, nil)
120
- @mappings[Lutaml::Model::Constants::RAW_MAPPING_KEY] = MappingRule.new(
177
+ @mappings[Lutaml::Model::Constants::RAW_MAPPING_KEY] = [MappingRule.new(
121
178
  Lutaml::Model::Constants::RAW_MAPPING_KEY,
122
179
  to: to,
123
180
  render_nil: render_nil,
124
181
  render_default: render_default,
125
182
  with: with,
126
183
  delegate: delegate,
127
- )
184
+ )]
128
185
  end
129
186
 
130
187
  alias map_all_content map_all
@@ -153,7 +210,9 @@ module Lutaml
153
210
  return if !instance_mapping?
154
211
 
155
212
  mapping_name = name_for_mapping(nil, key_name || @instance)
156
- @mappings[mapping_name].child_mappings = @key_mapping.merge(@value_mapping)
213
+ @mappings[mapping_name].each do |rule|
214
+ rule.child_mappings = @key_mapping.merge(@value_mapping)
215
+ end
157
216
  end
158
217
 
159
218
  def name_for_mapping(root_mappings, name)
@@ -164,7 +223,7 @@ module Lutaml
164
223
 
165
224
  def mappings(register_id = nil)
166
225
  ensure_mappings_imported!(register_id) if finalized?
167
- mappings_hash(register_id).values
226
+ mappings_hash(register_id).values.flatten
168
227
  end
169
228
 
170
229
  def mappings_hash(register_id = nil)
@@ -271,7 +330,7 @@ module Lutaml
271
330
  end
272
331
 
273
332
  def find_by_name(name)
274
- @mappings.find { |m| m.name.to_s == name.to_s }
333
+ mappings.find { |m| m.name.to_s == name.to_s }
275
334
  end
276
335
 
277
336
  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