lutaml-model 0.8.46 → 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: b2f28f3c9d10541b92778ec5ab996c7b41110bb5784b4832d08e650a758db5f8
4
- data.tar.gz: 22b516d73a1b76acf9421226b3f00204c3dec0cc46e4e1a9f1531ee979b1f8a2
3
+ metadata.gz: c4ba22dbb3a53b5ba0cdb11840f7940507da76da3d3b7b3ccd64e52f597bdfe1
4
+ data.tar.gz: '09d45f38541abb51224c9af9a4aff11d3207a99c4c9f79206cc0a6041caa382e'
5
5
  SHA512:
6
- metadata.gz: 779f779b5b3d76947306eec00d8652c4d8ca5e41a1a5846df1b34d5b52c03cc7e6302a53a4280d921aaddc96172daed52e1476b0662b60af40ea49558cba87f5
7
- data.tar.gz: 1e19049f5e0e8a57218bdd2d6a3366f018c37b53a773c6f660d18eb071fa7736db3521762040314097b0ab8b0d9507bd2a778a06af17719935c013639854ff82
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,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
  ----
@@ -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)
@@ -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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.46"
5
+ VERSION = "0.8.47"
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
 
@@ -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.47
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.