lutaml-model 0.8.46 → 0.8.48

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: b2f28f3c9d10541b92778ec5ab996c7b41110bb5784b4832d08e650a758db5f8
4
- data.tar.gz: 22b516d73a1b76acf9421226b3f00204c3dec0cc46e4e1a9f1531ee979b1f8a2
3
+ metadata.gz: a482362c928135d0733413ae200b20e5a9a7f05af8faf57696bc4461b093d691
4
+ data.tar.gz: 3b22e8a6881ad4a832d90a6b064d6999a5e5b8a969d507fd467d44c66f7c0279
5
5
  SHA512:
6
- metadata.gz: 779f779b5b3d76947306eec00d8652c4d8ca5e41a1a5846df1b34d5b52c03cc7e6302a53a4280d921aaddc96172daed52e1476b0662b60af40ea49558cba87f5
7
- data.tar.gz: 1e19049f5e0e8a57218bdd2d6a3366f018c37b53a773c6f660d18eb071fa7736db3521762040314097b0ab8b0d9507bd2a778a06af17719935c013639854ff82
6
+ metadata.gz: a6047ddb9f43fa9a7012e794070891a0d997ca261f2c76f324223a1c1414bab39f1f05b5f627ecde1e21a083b971c4cc002d15928db410e97411b5dcca9a5b97
7
+ data.tar.gz: b849ae1daa34c19f6f570d1389a2ea43cf3af5ffba5db32bdef33e53a0b7ba960736b511ecfc5f520a926690134a4247227b1fa7a437923a8774a511569f6e88
data/README.adoc CHANGED
@@ -2693,6 +2693,41 @@ references:
2693
2693
  ====
2694
2694
 
2695
2695
 
2696
+ ==== Attribute-value dispatch on a shared name (`when_attribute`)
2697
+
2698
+ Polymorphism selects the *class* of each item. The other dispatch axis
2699
+ selects *which attribute* an occurrence belongs to, keyed by a sibling
2700
+ value — every `<component>` below goes to a different attribute by its
2701
+ `type`:
2702
+
2703
+ [source,ruby]
2704
+ ----
2705
+ class Requirement < Lutaml::Model::Serializable
2706
+ attribute :guidance, Component, collection: true
2707
+ attribute :purpose, Component, collection: true
2708
+
2709
+ xml do
2710
+ element "requirement"
2711
+ map_element "component", when_attribute: "type",
2712
+ to: { "guidance" => :guidance,
2713
+ "purpose" => :purpose }
2714
+ end
2715
+ end
2716
+ ----
2717
+
2718
+ The same declaration works for key-value formats (`json`/`yaml`/`toml`/
2719
+ `hsh` blocks use `map`), where the discriminator is an object key's
2720
+ value. Every occurrence is captured exactly once, the discriminator is
2721
+ re-emitted on serialization, `unmatched: :raise` fails the parse on
2722
+ values no rule claims, and `ordered` XML mappings round-trip the
2723
+ original interleaving. The two axes compose on one rule — partition by
2724
+ value while dispatching classes with `polymorphic`.
2725
+
2726
+ See
2727
+ link:docs/_guides/advanced-mapping.adoc[the advanced mapping guide]
2728
+ for the full semantics.
2729
+
2730
+
2696
2731
 
2697
2732
  === Union attributes
2698
2733
 
@@ -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,10 +652,9 @@ 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
 
@@ -660,10 +676,8 @@ class KvRequirement < Lutaml::Model::Serializable
660
676
  attribute :purpose, Component, collection: true
661
677
 
662
678
  json do
663
- map "component", to: :guidance,
664
- when_attribute: { "type" => "guidance" }
665
- map "component", to: :purpose,
666
- when_attribute: { "type" => "purpose" }
679
+ map "component", when_attribute: "type",
680
+ to: { "guidance" => :guidance, "purpose" => :purpose }
667
681
  end
668
682
  end
669
683
  ----
@@ -23,6 +23,10 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
23
23
  * link:../keyvalue-serialization[Key-Value Serialization] - JSON/YAML/TOML/Hash
24
24
  * link:../collection-serialization[Collection Serialization] - JSONL and YAML Stream
25
25
 
26
+ == Advanced mapping
27
+
28
+ * link:../advanced-mapping[Advanced Attribute Mapping] - Same-name dispatch on attribute values (`when_attribute`), grouped declarations, custom methods, delegation
29
+
26
30
  == Performance
27
31
 
28
32
  * link:../native-engines[Native engines (leptris / yeptris / teptris)] - Drop-in XML/YAML/JSON/TOML engine upgrades via bundle opt-in
@@ -116,6 +116,40 @@ desc: A ceramic with a navy blue color and clear glaze.
116
116
  ----
117
117
  ====
118
118
 
119
+ === Dispatching a shared key by a discriminator value
120
+
121
+ When one wire key holds a list of objects distinguished by a sibling
122
+ key's value, `when_attribute` routes each item to its own attribute —
123
+ one declaration covers the whole partition:
124
+
125
+ [source,ruby]
126
+ ----
127
+ class Requirement < Lutaml::Model::Serializable
128
+ attribute :guidance, Component, collection: true
129
+ attribute :purpose, Component, collection: true
130
+
131
+ json do
132
+ map "component", when_attribute: "type",
133
+ to: { "guidance" => :guidance, "purpose" => :purpose }
134
+ end
135
+ end
136
+ ----
137
+
138
+ [source,json]
139
+ ----
140
+ { "component": [
141
+ { "type": "guidance", "text": "g1" },
142
+ { "type": "purpose", "text": "p1" }
143
+ ] }
144
+ ----
145
+
146
+ `guidance` holds `g1`, `purpose` holds `p1`. Serialization merges the
147
+ items back under the one key, each stamped with its discriminator.
148
+ `unmatched: :raise` fails the parse on items no rule claims. Works for
149
+ all key-value formats. See
150
+ link:advanced-mapping.adoc[the advanced mapping guide] for the full
151
+ semantics, including the per-rule form.
152
+
119
153
  === Specific format mappings
120
154
 
121
155
  Specific key value formats can be mapping independently of other formats.
@@ -85,6 +85,39 @@ module Lutaml
85
85
  )
86
86
  mapping_name = name_for_mapping(root_mappings, name)
87
87
  validate!(mapping_name, to, with, render_nil, render_empty)
88
+
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
+
88
121
  validate_when_attribute!(when_attribute) unless when_attribute.empty?
89
122
  validate_unmatched!(unmatched, when_attribute)
90
123
  if !when_attribute.empty? && (!with.empty? || delegate)
@@ -5,9 +5,12 @@ module Lutaml
5
5
  # Use child's own default register if it has one
6
6
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
7
7
  # are instantiated with their native context
8
- child_register = Lutaml::Model::Register.resolve_for_child(
9
- model_class, lutaml_register
10
- )
8
+ # TODO.max-perf/32: constant per (model class, register) — the
9
+ # transform itself is cached per that pair, so resolve once.
10
+ child_register = @kv_child_register ||= Lutaml::Model::Register
11
+ .resolve_for_child(
12
+ model_class, lutaml_register
13
+ )
11
14
 
12
15
  if model_class.include?(Lutaml::Model::Serialize)
13
16
  instance = model_class.new(lutaml_register: child_register)
@@ -449,7 +452,7 @@ partition = nil)
449
452
  if partitioned.nil? && (plan = kv_rule_plan(format, rule, attr)) &&
450
453
  (value = kv_fast_extract(doc, plan))
451
454
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
452
- attributes, self, options[:context])
455
+ attributes, self, options[:context], pre_cast: true)
453
456
  return
454
457
  end
455
458
 
@@ -485,6 +488,10 @@ partition = nil)
485
488
  if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
486
489
  attr.valid_collection!(value, context)
487
490
  end
491
+ # No pre_cast here: the interpretive attr.cast is not the full
492
+ # cast chain — whole-value Type policies (e.g. a custom
493
+ # Type::Value that receives the whole hash) are shaped by the
494
+ # setter's cast_value, which must still run.
488
495
  rule.deserialize(instance, value, attributes, self,
489
496
  options[:context])
490
497
  end
@@ -136,6 +136,47 @@ module Lutaml
136
136
  "unmatched only applies to rules declared with when_attribute"
137
137
  end
138
138
 
139
+ # lutaml-model#88: grouped discriminator form.
140
+ # `when_attribute: "type"` names the discriminator key; `to:` maps
141
+ # each wire value to its target attribute. Validates the shape and
142
+ # returns the group pairs — the DSL then expands one rule per
143
+ # value, so the compiled rules are identical to the per-rule form.
144
+ def when_attribute_group(when_attribute, to)
145
+ if when_attribute.to_s.empty?
146
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
147
+ "when_attribute group key cannot be empty"
148
+ end
149
+ unless to.is_a?(::Hash) && !to.empty?
150
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
151
+ "when_attribute: #{when_attribute.inspect} is the grouped " \
152
+ "form and requires to: { value => attribute }, got " \
153
+ "to: #{to.inspect}"
154
+ end
155
+
156
+ to.each do |value, target|
157
+ next if (value.is_a?(::String) || value.is_a?(::Symbol)) &&
158
+ (target.is_a?(::String) || target.is_a?(::Symbol))
159
+
160
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
161
+ "when_attribute group expects string/symbol values mapped " \
162
+ "to string/symbol attribute names, got " \
163
+ "#{value.inspect} => #{target.inspect}"
164
+ end
165
+ to
166
+ end
167
+
168
+ # The per-rule `{ name => value }` form maps ONE attribute; a Hash
169
+ # `to:` only makes sense with the grouped form.
170
+ def reject_ambiguous_when_attribute!(when_attribute, to)
171
+ return unless when_attribute.is_a?(::Hash) && !when_attribute.empty? &&
172
+ to.is_a?(::Hash)
173
+
174
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
175
+ "to: must name a single attribute when when_attribute is the " \
176
+ "per-rule { name => value } form; the grouped form is " \
177
+ 'when_attribute: "key", to: { value => attribute }'
178
+ end
179
+
139
180
  def register(register_id = nil)
140
181
  register_id ||= Lutaml::Model::Config.default_register
141
182
  Lutaml::Model::GlobalRegister.lookup(register_id)
@@ -324,13 +324,15 @@ module Lutaml
324
324
  end
325
325
 
326
326
  def deserialize(model, value, attributes, mapper_class = nil,
327
- context = nil)
327
+ context = nil, pre_cast: false)
328
328
  if @needs_full_deserialize
329
329
  handle_custom_method(model, value, mapper_class, context) ||
330
330
  handle_delegate(model, value, attributes) ||
331
- handle_transform_method(model, value, attributes, context)
331
+ handle_transform_method(model, value, attributes, context,
332
+ pre_cast: pre_cast)
332
333
  else
333
- handle_transform_method(model, value, attributes, context)
334
+ handle_transform_method(model, value, attributes, context,
335
+ pre_cast: pre_cast)
334
336
  end
335
337
  end
336
338
 
@@ -446,6 +448,24 @@ context = nil)
446
448
  # :import — hash/proc transformers go through ImportTransformer.
447
449
  TRANSFORM_DISPATCH = {}.compare_by_identity
448
450
 
451
+ def self.parsed_assign_writer(model_class, target)
452
+ key = [model_class, target]
453
+ writer = PARSED_ASSIGN_WRITERS[key]
454
+ return writer unless writer.nil?
455
+
456
+ name = :"__assign_parsed_#{target}="
457
+ writer = model_class.method_defined?(name) ? name : false
458
+ PARSED_ASSIGN_WRITERS[key] = writer
459
+ writer
460
+ end
461
+
462
+ # [model class, target] -> the parsed-assign writer compiled for
463
+ # that class's attribute, or nil when only a casting writer
464
+ # exists (custom writers, reflective names, enum shorthands).
465
+ # Class-level because mapping rules may be frozen (see the
466
+ # name-string note above TRANSFORM_DISPATCH).
467
+ PARSED_ASSIGN_WRITERS = {}.compare_by_identity
468
+
449
469
  def self.transform_dispatch(rule, attr)
450
470
  per_attr = TRANSFORM_DISPATCH[rule]
451
471
  if per_attr.nil?
@@ -556,7 +576,8 @@ context = nil)
556
576
  attributes[delegate].type(model.lutaml_register).new)
557
577
  end
558
578
 
559
- def handle_transform_method(model, value, attributes, context = nil)
579
+ def handle_transform_method(model, value, attributes, context = nil,
580
+ pre_cast: false)
560
581
  attr = attributes[to]
561
582
  # The transform verdict (none / class-based already applied /
562
583
  # hash-proc via ImportTransformer) is static per (rule, attr) —
@@ -566,12 +587,28 @@ context = nil)
566
587
  transformed = ImportTransformer.call(value, self, attr,
567
588
  context: context)
568
589
  assign_value(model, transformed)
590
+ elsif pre_cast && collection_shaped?(value, attr) &&
591
+ (writer = self.class.parsed_assign_writer(model.class, to))
592
+ # TODO.max-perf/31: the deserializing transform already cast
593
+ # the value through the format-aware path — assign without
594
+ # repeating the cast. Custom writers and reflective names
595
+ # resolve nil here and keep the casting setter.
596
+ model.public_send(writer, value)
569
597
  else
570
598
  assign_value(model, value)
571
599
  end
572
600
  true
573
601
  end
574
602
 
603
+ # Whether the value already carries the attribute's final shape:
604
+ # a bare single occurrence still owes the setter's one-element
605
+ # collection coercion, so only collection instances (and every
606
+ # singular value, whose cast is complete) take the no-recast lane.
607
+ def collection_shaped?(value, attr)
608
+ attr.nil? || !attr.collection? || value.is_a?(::Array) ||
609
+ value.is_a?(Lutaml::Model::Collection)
610
+ end
611
+
575
612
  def assign_value(model, value)
576
613
  model.public_send(@setter_name, value)
577
614
  end
@@ -297,6 +297,21 @@ module Lutaml
297
297
  end
298
298
  record_mutation_collection(:#{name}, value)
299
299
  end
300
+
301
+ # TODO.max-perf/31: the deserialization transforms already
302
+ # cast through the format-aware path; this writer skips the
303
+ # setter's re-cast while keeping value_set_for marking and
304
+ # mutation recording. Public assignment keeps the caster.
305
+ def __assign_parsed_#{name}=(value)
306
+ value_set_for(:#{name})
307
+ current = @#{name}
308
+ if current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
309
+ (value.nil? || Lutaml::Model::Utils.uninitialized?(value))
310
+ else
311
+ @#{name} = value
312
+ end
313
+ record_mutation_collection(:#{name}, value)
314
+ end
300
315
  RUBY
301
316
  else
302
317
  class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
@@ -306,6 +321,13 @@ module Lutaml
306
321
  @#{name} = value
307
322
  record_mutation(:#{name}, value)
308
323
  end
324
+
325
+ # TODO.max-perf/31: see the collection branch above.
326
+ def __assign_parsed_#{name}=(value)
327
+ value_set_for(:#{name})
328
+ @#{name} = value
329
+ record_mutation(:#{name}, value)
330
+ end
309
331
  RUBY
310
332
  end
311
333
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.46"
5
+ VERSION = "0.8.48"
6
6
  end
7
7
  end
@@ -539,6 +539,48 @@ module Lutaml
539
539
  name, to, with, render_nil, render_empty, type: TYPES[:element]
540
540
  )
541
541
 
542
+ # lutaml-model#88: grouped form — `when_attribute: "type"` plus
543
+ # `to: { value => attribute }` expands to one rule per value at
544
+ # DSL time, so the compiled rules are identical to the per-rule
545
+ # form and nothing downstream changes.
546
+ if when_attribute.is_a?(::String) || when_attribute.is_a?(::Symbol)
547
+ # These locals are `false` when their kwarg default ran and
548
+ # nil when the caller passed the kwarg explicitly (planned
549
+ # locals read as nil, so defined? is useless here).
550
+ if namespace_set.nil? || prefix_provided.nil? ||
551
+ xsd_type_provided.nil?
552
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
553
+ "namespace/prefix/xsd_type are not supported in the " \
554
+ "grouped when_attribute form; declare rules individually"
555
+ end
556
+
557
+ when_attribute_group(when_attribute, to).each do |value, target|
558
+ map_element(
559
+ name,
560
+ to: target,
561
+ render_nil: render_nil,
562
+ render_default: render_default,
563
+ render_empty: render_empty,
564
+ treat_nil: treat_nil,
565
+ treat_empty: treat_empty,
566
+ treat_omitted: treat_omitted,
567
+ with: with,
568
+ delegate: delegate,
569
+ cdata: cdata,
570
+ polymorphic: polymorphic,
571
+ transform: transform,
572
+ value_map: value_map,
573
+ when_attribute: { when_attribute.to_s => value.to_s },
574
+ unmatched: unmatched,
575
+ form: form,
576
+ documentation: documentation,
577
+ raw: raw,
578
+ )
579
+ end
580
+ return
581
+ end
582
+ reject_ambiguous_when_attribute!(when_attribute, to)
583
+
542
584
  validate_when_attribute!(when_attribute) unless when_attribute.empty?
543
585
  validate_unmatched!(unmatched, when_attribute)
544
586
 
@@ -48,9 +48,12 @@ module Lutaml
48
48
  # Use child's own default register if it has one
49
49
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
50
50
  # are instantiated with their native context
51
- child_register = Lutaml::Model::Register.resolve_for_child(
52
- model_class, lutaml_register
53
- )
51
+ # TODO.max-perf/32: constant per (model class, register) — the
52
+ # transform itself is cached per that pair, so resolve once.
53
+ child_register = @xml_child_register ||= Lutaml::Model::Register
54
+ .resolve_for_child(
55
+ model_class, lutaml_register
56
+ )
54
57
 
55
58
  instance_is_serialize = model_class.include?(::Lutaml::Model::Serialize)
56
59
  if instance_is_serialize
@@ -285,6 +285,86 @@ RSpec.describe "when_attribute for key-value formats" do
285
285
  end
286
286
  end
287
287
 
288
+ describe "grouped form" do
289
+ let(:grouped) do
290
+ Class.new(Lutaml::Model::Serializable) do
291
+ attribute :guidance, KvWhenComponent, collection: true
292
+ attribute :purpose, KvWhenComponent, collection: true
293
+
294
+ json do
295
+ map "component", when_attribute: "type",
296
+ to: { guidance: :guidance, purpose: :purpose },
297
+ unmatched: :raise
298
+ end
299
+
300
+ hsh do
301
+ map "component", when_attribute: "type",
302
+ to: { guidance: :guidance, purpose: :purpose }
303
+ end
304
+ end
305
+ end
306
+
307
+ it "expands to one rule per value with identical behavior" do
308
+ stub_const("KvWhen::Grouped", grouped)
309
+ req = grouped.from_json(payload.to_json)
310
+
311
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
312
+ expect(req.purpose.map(&:text)).to eq(["p1"])
313
+ expect(JSON.parse(req.to_json)["component"]).to eq([
314
+ { "type" => "guidance", "text" => "g1" },
315
+ { "type" => "guidance", "text" => "g2" },
316
+ { "type" => "purpose", "text" => "p1" },
317
+ ])
318
+
319
+ rules = grouped.mappings_for(:json).mappings.select { |r| r.name == "component" }
320
+ expect(rules.map(&:when_attribute)).to eq(
321
+ [{ "type" => "guidance" }, { "type" => "purpose" }],
322
+ )
323
+ end
324
+
325
+ it "works through the hash format" do
326
+ stub_const("KvWhen::Grouped", grouped)
327
+ req = grouped.from_hash(
328
+ "component" => [{ type: "purpose", text: "p1" }],
329
+ )
330
+
331
+ expect(req.purpose.map(&:text)).to eq(["p1"])
332
+ expect(req.guidance).to be_empty
333
+ end
334
+
335
+ it "forwards the unmatched policy to every rule" do
336
+ stub_const("KvWhen::Grouped", grouped)
337
+
338
+ expect do
339
+ grouped.from_json({ "component" => [{ "type" => "nope" }] }.to_json)
340
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="nope"/)
341
+ end
342
+
343
+ it "rejects the grouped form without a to: map" do
344
+ expect do
345
+ Class.new(Lutaml::Model::Serializable) do
346
+ attribute :x, :string
347
+
348
+ json do
349
+ map "x", to: :x, when_attribute: "type"
350
+ end
351
+ end
352
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /to:/)
353
+ end
354
+
355
+ it "rejects a Hash to: with the per-rule form" do
356
+ expect do
357
+ Class.new(Lutaml::Model::Serializable) do
358
+ attribute :x, :string
359
+
360
+ json do
361
+ map "x", when_attribute: { "type" => "a" }, to: { "a" => :x }
362
+ end
363
+ end
364
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /single attribute/)
365
+ end
366
+ end
367
+
288
368
  describe "DSL validation" do
289
369
  it "rejects non-string/symbol pairs" do
290
370
  expect do
@@ -131,6 +131,99 @@ RSpec.describe "when_attribute discriminator mappings" do
131
131
  end.to raise_error(ArgumentError)
132
132
  end
133
133
 
134
+ describe "grouped form" do
135
+ let(:grouped) do
136
+ Class.new(Lutaml::Model::Serializable) do
137
+ attribute :guidance, WhenAttrComponent, collection: true
138
+ attribute :purpose, WhenAttrComponent, collection: true
139
+
140
+ xml do
141
+ element "requirement"
142
+ map_element "component", when_attribute: "type",
143
+ to: { "guidance" => :guidance,
144
+ "purpose" => :purpose },
145
+ unmatched: :raise
146
+ end
147
+ end
148
+ end
149
+
150
+ let(:grouped_xml) do
151
+ <<~XML
152
+ <requirement>
153
+ <component type="guidance"><text>g1</text></component>
154
+ <component type="purpose"><text>p1</text></component>
155
+ <component type="guidance"><text>g2</text></component>
156
+ </requirement>
157
+ XML
158
+ end
159
+
160
+ it "expands to one rule per value with identical behavior" do
161
+ stub_const("WhenAttr::Grouped", grouped)
162
+ req = grouped.from_xml(grouped_xml)
163
+
164
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
165
+ expect(req.purpose.map(&:text)).to eq(["p1"])
166
+ expect(req.to_xml).to include('type="guidance"')
167
+
168
+ rules = grouped.mappings_for(:xml).mappings.select { |r| r.name == "component" }
169
+ expect(rules.map(&:when_attribute)).to eq(
170
+ [{ "type" => "guidance" }, { "type" => "purpose" }],
171
+ )
172
+ end
173
+
174
+ it "forwards the unmatched policy to every rule" do
175
+ stub_const("WhenAttr::Grouped", grouped)
176
+
177
+ expect do
178
+ grouped.from_xml(
179
+ '<requirement><component type="other"><text>x</text></component></requirement>',
180
+ )
181
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="other"/)
182
+ end
183
+
184
+ it "rejects the grouped form without a to: map" do
185
+ expect do
186
+ Class.new(Lutaml::Model::Serializable) do
187
+ attribute :x, :string
188
+
189
+ xml do
190
+ element "x"
191
+ map_element "x", to: :x, when_attribute: "type"
192
+ end
193
+ end
194
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /to:/)
195
+ end
196
+
197
+ it "rejects a Hash to: with the per-rule form" do
198
+ expect do
199
+ Class.new(Lutaml::Model::Serializable) do
200
+ attribute :x, :string
201
+
202
+ xml do
203
+ element "x"
204
+ map_element "x", when_attribute: { "type" => "a" },
205
+ to: { "a" => :x }
206
+ end
207
+ end
208
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /single attribute/)
209
+ end
210
+
211
+ it "rejects namespace in the grouped form" do
212
+ expect do
213
+ Class.new(Lutaml::Model::Serializable) do
214
+ attribute :x, :string
215
+
216
+ xml do
217
+ element "x"
218
+ map_element "x", when_attribute: "type",
219
+ to: { "a" => :x },
220
+ namespace: "https://example.com"
221
+ end
222
+ end
223
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /grouped/)
224
+ end
225
+ end
226
+
134
227
  # Orthogonality guard: `when_attribute` partitions occurrences across
135
228
  # attributes; `polymorphic` dispatches the class of each hydrated item.
136
229
  # Different axes over the same discriminator concept — they compose on
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.46
4
+ version: 0.8.48
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.