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 +4 -4
- data/docs/_guides/advanced-mapping.adoc +29 -15
- data/lib/lutaml/key_value/mapping.rb +33 -0
- data/lib/lutaml/model/mapping/mapping.rb +41 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping.rb +42 -0
- data/spec/lutaml/key_value/when_attribute_spec.rb +80 -0
- data/spec/lutaml/xml/when_attribute_spec.rb +93 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4ba22dbb3a53b5ba0cdb11840f7940507da76da3d3b7b3ccd64e52f597bdfe1
|
|
4
|
+
data.tar.gz: '09d45f38541abb51224c9af9a4aff11d3207a99c4c9f79206cc0a6041caa382e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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",
|
|
580
|
-
|
|
581
|
-
|
|
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",
|
|
611
|
-
|
|
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",
|
|
639
|
-
|
|
640
|
-
|
|
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",
|
|
664
|
-
|
|
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)
|
data/lib/lutaml/model/version.rb
CHANGED
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -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
|