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 +4 -4
- data/README.adoc +35 -0
- data/docs/_guides/advanced-mapping.adoc +29 -15
- data/docs/_guides/index.adoc +4 -0
- data/docs/_guides/keyvalue-serialization.adoc +34 -0
- data/lib/lutaml/key_value/mapping.rb +33 -0
- data/lib/lutaml/key_value/transform.rb +11 -4
- data/lib/lutaml/model/mapping/mapping.rb +41 -0
- data/lib/lutaml/model/mapping/mapping_rule.rb +41 -4
- data/lib/lutaml/model/serialize/attribute_definition.rb +22 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping.rb +42 -0
- data/lib/lutaml/xml/model_transform.rb +6 -3
- 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: a482362c928135d0733413ae200b20e5a9a7f05af8faf57696bc4461b093d691
|
|
4
|
+
data.tar.gz: 3b22e8a6881ad4a832d90a6b064d6999a5e5b8a969d507fd467d44c66f7c0279
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
----
|
data/docs/_guides/index.adoc
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
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
|
|
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|