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 +4 -4
- data/docs/_guides/advanced-mapping.adoc +77 -11
- data/lib/lutaml/key_value/mapping.rb +65 -6
- data/lib/lutaml/key_value/mapping_rule.rb +9 -0
- data/lib/lutaml/key_value/transform.rb +186 -7
- data/lib/lutaml/key_value/transformation/rule_compiler.rb +2 -0
- data/lib/lutaml/key_value/transformation.rb +113 -2
- data/lib/lutaml/model/mapping/mapping.rb +71 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/mapping.rb +42 -29
- data/spec/lutaml/key_value/when_attribute_spec.rb +428 -0
- data/spec/lutaml/xml/when_attribute_spec.rb +93 -0
- metadata +2 -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,13 +652,62 @@ 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
|
|
|
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
|
-
|
|
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].
|
|
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
|
-
|
|
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)
|
|
22
|
-
|
|
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
|
-
|
|
58
|
+
handled_groups = nil
|
|
59
|
+
rules.each do |rule|
|
|
53
60
|
next unless valid_mapping?(rule, options)
|
|
54
61
|
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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? ||
|
|
@@ -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
|
-
|
|
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
|