lutaml-model 0.8.44 → 0.8.46

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.
data/lib/lutaml/model.rb CHANGED
@@ -138,6 +138,8 @@ module Lutaml
138
138
  autoload :ElementCountOutOfRangeError,
139
139
  "#{__dir__}/model/error/element_count_out_of_range_error"
140
140
  autoload :ValidationError, "#{__dir__}/model/error/validation_error"
141
+ autoload :UnknownDiscriminatorError,
142
+ "#{__dir__}/model/error/unknown_discriminator_error"
141
143
  autoload :TypeNotEnabledError,
142
144
  "#{__dir__}/model/error/type_not_enabled_error"
143
145
  autoload :TypeError, "#{__dir__}/model/error/type_error"
@@ -528,6 +528,7 @@ module Lutaml
528
528
  transform: {},
529
529
  value_map: {},
530
530
  when_attribute: {},
531
+ unmatched: :drop,
531
532
  form: nil,
532
533
  documentation: nil,
533
534
  xsd_type: (xsd_type_provided = false
@@ -539,6 +540,7 @@ module Lutaml
539
540
  )
540
541
 
541
542
  validate_when_attribute!(when_attribute) unless when_attribute.empty?
543
+ validate_unmatched!(unmatched, when_attribute)
542
544
 
543
545
  # Warn if prefix parameter is provided
544
546
  if prefix_provided != false
@@ -588,6 +590,7 @@ module Lutaml
588
590
  documentation: documentation,
589
591
  raw: raw,
590
592
  when_attribute: when_attribute,
593
+ unmatched: unmatched,
591
594
  )
592
595
  # Store rules with the same element name in an array to support
593
596
  # multiple mapping rules for the same element name with different target types
@@ -611,20 +614,6 @@ module Lutaml
611
614
  end
612
615
  end
613
616
 
614
- # lutaml-model#88: attribute-value discriminator. Keys are wire
615
- # attribute names, values the expected string value.
616
- def validate_when_attribute!(when_attribute)
617
- when_attribute.each do |k, v|
618
- next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
619
- (v.is_a?(::String) || v.is_a?(::Symbol))
620
-
621
- raise Lutaml::Model::IncorrectMappingArgumentsError,
622
- "when_attribute expects string/symbol attribute names " \
623
- "mapped to string/symbol values, got " \
624
- "#{k.inspect} => #{v.inspect}"
625
- end
626
- end
627
-
628
617
  def map_attribute(
629
618
  name,
630
619
  to: nil,
@@ -11,7 +11,8 @@ module Lutaml
11
11
  :delimiter,
12
12
  :form,
13
13
  :documentation,
14
- :raw
14
+ :raw,
15
+ :unmatched
15
16
 
16
17
  # Writers for deep_dup (preserves exact object references)
17
18
  attr_accessor :namespace, :prefix, :namespace_class
@@ -38,6 +39,7 @@ module Lutaml
38
39
  transform: {},
39
40
  value_map: {},
40
41
  when_attribute: {},
42
+ unmatched: :drop,
41
43
  as_list: nil,
42
44
  delimiter: nil,
43
45
  form: nil,
@@ -66,6 +68,9 @@ module Lutaml
66
68
  # Store original namespace parameter to preserve :inherit symbol
67
69
  @namespace_param = namespace
68
70
 
71
+ # lutaml-model#88: policy for occurrences claimed by no rule
72
+ @unmatched = unmatched
73
+
69
74
  # Normalize namespace to XmlNamespace class
70
75
  @namespace_class = normalize_namespace(namespace)
71
76
  @namespace = @namespace_class == :blank ? nil : @namespace_class&.uri
@@ -273,6 +278,7 @@ module Lutaml
273
278
  documentation: @documentation,
274
279
  raw: @raw,
275
280
  when_attribute: when_attribute,
281
+ unmatched: @unmatched,
276
282
  ).tap do |dup_rule|
277
283
  # Manually preserve the exact @namespace_class object to avoid
278
284
  # recreating anonymous classes (which would have different object_ids)
@@ -859,6 +859,35 @@ _effective_register)
859
859
  cache[type_class] = type_class.mappings_for(:xml)&.namespace_class
860
860
  end
861
861
 
862
+ # lutaml-model#88: fail closed for `unmatched: :raise`. An
863
+ # occurrence no rule on the wire name claims — no discriminator
864
+ # match, no plain sibling — is exactly the data the default policy
865
+ # silently drops.
866
+ def check_unmatched_discriminators!(children, rule, rule_names, session)
867
+ plain_names = session.plain_element_rule_names
868
+ return if rule_names.any? { |name| plain_names[name] }
869
+
870
+ group = rule_names.filter_map do |name|
871
+ session.when_attribute_siblings_by_name[name]
872
+ end.flatten
873
+ tested = group.flat_map { |sibling| sibling.when_attribute.keys }
874
+ .uniq.map(&:to_s)
875
+ children.each do |child|
876
+ next if group.any? { |sibling| sibling.matches_when_attribute?(child) }
877
+
878
+ values = tested.filter_map do |name|
879
+ value = child.find_attribute_value(name)
880
+ "#{name}=#{value.inspect}" if value
881
+ end
882
+ raise ::Lutaml::Model::UnknownDiscriminatorError,
883
+ "Element <#{rule.name}> (#{values.join(', ')}) is claimed by " \
884
+ "no rule: it matches no when_attribute discriminator and no " \
885
+ "plain rule shares the name. Cover the value, add a plain " \
886
+ "rule, or opt out with unmatched: :drop"
887
+ end
888
+ nil
889
+ end
890
+
862
891
  def value_for_rule(session, rule, options, cached_attr = nil,
863
892
  extra_rule_names = nil)
864
893
  doc = session.doc
@@ -1058,6 +1087,9 @@ _effective_register)
1058
1087
  # no discriminator claimed — so each occurrence is captured
1059
1088
  # exactly once, mirroring ordered-serialization routing.
1060
1089
  if rule.when_attribute?
1090
+ if rule.unmatched == :raise
1091
+ check_unmatched_discriminators!(children, rule, rule_names, session)
1092
+ end
1061
1093
  children = children.select do |child|
1062
1094
  rule.matches_when_attribute?(child)
1063
1095
  end
@@ -66,27 +66,49 @@ module Lutaml
66
66
  end
67
67
 
68
68
  # lutaml-model#88: element wire names partitioned by `when_attribute`
69
- # rules, as name -> discriminator rules. A plain rule on a
70
- # partitioned name captures only occurrences no discriminator
71
- # claimed — the mirror of ordered-serialization routing, so each
72
- # occurrence is captured exactly once instead of double-captured by
73
- # the plain rule and its discriminator sibling. Empty unless the
74
- # mapping uses when_attribute at all; non-Serialize custom models
75
- # have no mappings to partition.
76
- def when_attribute_siblings_by_name
77
- @when_attribute_siblings_by_name ||=
69
+ # rules. One pass builds two views over the mapping's element rules:
70
+ #
71
+ # - name -> discriminator rules. A plain rule on a partitioned name
72
+ # captures only occurrences no discriminator claimed — the mirror
73
+ # of ordered-serialization routing, so each occurrence is captured
74
+ # exactly once instead of double-captured by the plain rule and
75
+ # its discriminator sibling.
76
+ # - name -> true when a plain element rule also holds the name.
77
+ # Everything on the name is then claimed, so `unmatched: :raise`
78
+ # never fires.
79
+ #
80
+ # Empty unless the mapping uses when_attribute at all; non-Serialize
81
+ # custom models have no mappings to partition.
82
+ def when_attribute_partition
83
+ @when_attribute_partition ||=
78
84
  if instance_is_serialize && (mapping = xml_mapping)
79
- mapping.mappings.each_with_object({}) do |rule, index|
80
- pairs = rule.when_attribute
81
- next if pairs.nil? || pairs.empty?
85
+ siblings = {}
86
+ plain_names = {}
87
+ mapping.mappings.each do |rule|
88
+ next if rule.attribute? || rule.content_mapping? ||
89
+ rule.raw_mapping? || rule.cdata
82
90
 
83
- (index[rule.name.to_s] ||= []) << rule
91
+ pairs = rule.when_attribute
92
+ if pairs && !pairs.empty?
93
+ (siblings[rule.name.to_s] ||= []) << rule
94
+ else
95
+ plain_names[rule.name.to_s] = true
96
+ end
84
97
  end
98
+ [siblings, plain_names]
85
99
  else
86
- {}
100
+ [{}, {}]
87
101
  end
88
102
  end
89
103
 
104
+ def when_attribute_siblings_by_name
105
+ when_attribute_partition[0]
106
+ end
107
+
108
+ def plain_element_rule_names
109
+ when_attribute_partition[1]
110
+ end
111
+
90
112
  def model_class
91
113
  @model_class ||= instance.class
92
114
  end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: `when_attribute` partitioning for key-value formats.
6
+ # The discriminator is an object key: items of the array at a shared wire
7
+ # key are claimed by the rule whose pairs their key values satisfy.
8
+ # Serialization merges every rule's items back under the one key, each
9
+ # item stamped with its rule's discriminator pairs.
10
+ class KvWhenComponent < Lutaml::Model::Serializable
11
+ attribute :text, :string
12
+
13
+ json do
14
+ map "text", to: :text
15
+ end
16
+
17
+ yaml do
18
+ map "text", to: :text
19
+ end
20
+
21
+ toml do
22
+ map "text", to: :text
23
+ end
24
+
25
+ hsh do
26
+ map "text", to: :text
27
+ end
28
+ end
29
+
30
+ class KvWhenRequirement < Lutaml::Model::Serializable
31
+ attribute :guidance, KvWhenComponent, collection: true
32
+ attribute :purpose, KvWhenComponent, collection: true
33
+
34
+ json do
35
+ map "component", to: :guidance,
36
+ when_attribute: { "type" => "guidance" }
37
+ map "component", to: :purpose,
38
+ when_attribute: { "type" => "purpose" }
39
+ end
40
+
41
+ yaml do
42
+ map "component", to: :guidance,
43
+ when_attribute: { "type" => "guidance" }
44
+ map "component", to: :purpose,
45
+ when_attribute: { "type" => "purpose" }
46
+ end
47
+
48
+ hsh do
49
+ map "component", to: :guidance,
50
+ when_attribute: { "type" => "guidance" }
51
+ map "component", to: :purpose,
52
+ when_attribute: { "type" => "purpose" }
53
+ end
54
+ end
55
+
56
+ RSpec.describe "when_attribute for key-value formats" do
57
+ before do
58
+ stub_const("KvWhen::Component", KvWhenComponent)
59
+ stub_const("KvWhen::Requirement", KvWhenRequirement)
60
+ end
61
+
62
+ let(:payload) do
63
+ {
64
+ "component" => [
65
+ { "type" => "guidance", "text" => "g1" },
66
+ { "type" => "purpose", "text" => "p1" },
67
+ { "type" => "guidance", "text" => "g2" },
68
+ ],
69
+ }
70
+ end
71
+
72
+ it "partitions json by the discriminator key" do
73
+ req = KvWhenRequirement.from_json(payload.to_json)
74
+
75
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
76
+ expect(req.purpose.map(&:text)).to eq(["p1"])
77
+ end
78
+
79
+ it "partitions yaml arrays of mappings" do
80
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
81
+
82
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
83
+ expect(req.purpose.map(&:text)).to eq(["p1"])
84
+ end
85
+
86
+ it "partitions hashes passed directly with symbol keys" do
87
+ req = KvWhenRequirement.from_hash(
88
+ component: [
89
+ { type: "guidance", text: "g1" },
90
+ { type: "purpose", text: "p1" },
91
+ ],
92
+ )
93
+
94
+ expect(req.guidance.map(&:text)).to eq(["g1"])
95
+ expect(req.purpose.map(&:text)).to eq(["p1"])
96
+ end
97
+
98
+ it "keeps a single object occurrence as one item" do
99
+ req = KvWhenRequirement.from_hash(
100
+ "component" => { "type" => "purpose", "text" => "p1" },
101
+ )
102
+
103
+ expect(req.purpose.map(&:text)).to eq(["p1"])
104
+ expect(req.guidance).to be_empty
105
+ end
106
+
107
+ it "serializes one merged array with the discriminators stamped" do
108
+ req = KvWhenRequirement.from_json(payload.to_json)
109
+ out = req.to_json
110
+
111
+ items = JSON.parse(out)["component"]
112
+ expect(items).to eq([
113
+ { "type" => "guidance", "text" => "g1" },
114
+ { "type" => "guidance", "text" => "g2" },
115
+ { "type" => "purpose", "text" => "p1" },
116
+ ])
117
+ end
118
+
119
+ it "round-trips through yaml" do
120
+ req = KvWhenRequirement.from_yaml(payload.to_yaml)
121
+ again = KvWhenRequirement.from_yaml(req.to_yaml)
122
+
123
+ expect(again.guidance.map(&:text)).to eq(%w[g1 g2])
124
+ expect(again.purpose.map(&:text)).to eq(["p1"])
125
+ end
126
+
127
+ it "does not stamp over a key the item's own mapping writes" do
128
+ holder = Class.new(Lutaml::Model::Serializable) do
129
+ attribute :items, KvWhenComponent, collection: true
130
+
131
+ json do
132
+ map "component", to: :items,
133
+ when_attribute: { "type" => "guidance" }
134
+ end
135
+
136
+ hsh do
137
+ map "component", to: :items,
138
+ when_attribute: { "type" => "guidance" }
139
+ end
140
+ end
141
+ stub_const("KvWhen::Holder", holder)
142
+
143
+ out = holder.from_hash(
144
+ "component" => [{ "type" => "guidance", "text" => "g" }],
145
+ ).to_json
146
+ expect(JSON.parse(out)["component"]).to eq(
147
+ [{ "type" => "guidance", "text" => "g" }],
148
+ )
149
+ end
150
+
151
+ describe "plain rule complement" do
152
+ let(:holder) do
153
+ Class.new(Lutaml::Model::Serializable) do
154
+ attribute :special, KvWhenComponent, collection: true
155
+ attribute :plain, KvWhenComponent, collection: true
156
+
157
+ json do
158
+ map "item", to: :special, when_attribute: { "kind" => "special" }
159
+ map "item", to: :plain
160
+ end
161
+
162
+ hsh do
163
+ map "item", to: :special, when_attribute: { "kind" => "special" }
164
+ map "item", to: :plain
165
+ end
166
+ end
167
+ end
168
+
169
+ it "captures occurrences no discriminator claimed exactly once" do
170
+ stub_const("KvWhen::Both", holder)
171
+ doc = holder.from_hash(
172
+ "item" => [
173
+ { "kind" => "special", "text" => "b" },
174
+ { "text" => "a" },
175
+ { "kind" => "other", "text" => "x" },
176
+ ],
177
+ )
178
+
179
+ expect(doc.special.map(&:text)).to eq(["b"])
180
+ expect(doc.plain.map(&:text)).to eq(%w[a x])
181
+ end
182
+
183
+ it "merges plain and stamped items into the one key" do
184
+ stub_const("KvWhen::Both", holder)
185
+ doc = holder.from_hash(
186
+ "item" => [
187
+ { "kind" => "special", "text" => "b" },
188
+ { "text" => "a" },
189
+ ],
190
+ )
191
+ items = JSON.parse(doc.to_json)["item"]
192
+
193
+ expect(items).to eq([
194
+ { "kind" => "special", "text" => "b" },
195
+ { "text" => "a" },
196
+ ])
197
+ end
198
+ end
199
+
200
+ describe "unmatched policy" do
201
+ let(:strict) do
202
+ Class.new(Lutaml::Model::Serializable) do
203
+ attribute :guidance, KvWhenComponent, collection: true
204
+
205
+ json do
206
+ map "component", to: :guidance,
207
+ when_attribute: { "type" => "guidance" },
208
+ unmatched: :raise
209
+ end
210
+ end
211
+ end
212
+
213
+ it "raises on an unclaimed discriminator value" do
214
+ stub_const("KvWhen::Strict", strict)
215
+
216
+ expect do
217
+ strict.from_json({ "component" => [
218
+ { "type" => "guidance", "text" => "g" },
219
+ { "type" => "nope", "text" => "x" },
220
+ ] }.to_json)
221
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="nope"/)
222
+ end
223
+
224
+ it "raises on an item without the discriminator key" do
225
+ stub_const("KvWhen::Strict", strict)
226
+
227
+ expect do
228
+ strict.from_json({ "component" => [{ "text" => "x" }] }.to_json)
229
+ end.to raise_error(Lutaml::Model::UnknownDiscriminatorError, /<component>/)
230
+ end
231
+
232
+ it "drops unclaimed occurrences by default" do
233
+ lenient = Class.new(Lutaml::Model::Serializable) do
234
+ attribute :guidance, KvWhenComponent, collection: true
235
+
236
+ json do
237
+ map "component", to: :guidance,
238
+ when_attribute: { "type" => "guidance" }
239
+ end
240
+ end
241
+ stub_const("KvWhen::Lenient", lenient)
242
+
243
+ doc = lenient.from_json({ "component" => [
244
+ { "type" => "guidance", "text" => "g" },
245
+ { "type" => "other", "text" => "x" },
246
+ ] }.to_json)
247
+ expect(doc.guidance.map(&:text)).to eq(["g"])
248
+ end
249
+ end
250
+
251
+ describe "toml arrays of tables" do
252
+ let(:toml_holder) do
253
+ Class.new(Lutaml::Model::Serializable) do
254
+ attribute :guidance, KvWhenComponent, collection: true
255
+ attribute :purpose, KvWhenComponent, collection: true
256
+
257
+ toml do
258
+ map "component", to: :guidance,
259
+ when_attribute: { "type" => "guidance" }
260
+ map "component", to: :purpose,
261
+ when_attribute: { "type" => "purpose" }
262
+ end
263
+ end
264
+ end
265
+
266
+ it "partitions and round-trips" do
267
+ stub_const("KvWhen::TomlHolder", toml_holder)
268
+ src = <<~TOML
269
+ [[component]]
270
+ type = "guidance"
271
+ text = "g1"
272
+
273
+ [[component]]
274
+ type = "purpose"
275
+ text = "p1"
276
+ TOML
277
+
278
+ doc = toml_holder.from_toml(src)
279
+ expect(doc.guidance.map(&:text)).to eq(["g1"])
280
+ expect(doc.purpose.map(&:text)).to eq(["p1"])
281
+
282
+ again = toml_holder.from_toml(doc.to_toml)
283
+ expect(again.guidance.map(&:text)).to eq(["g1"])
284
+ expect(again.purpose.map(&:text)).to eq(["p1"])
285
+ end
286
+ end
287
+
288
+ describe "DSL validation" do
289
+ it "rejects non-string/symbol pairs" do
290
+ expect do
291
+ Class.new(Lutaml::Model::Serializable) do
292
+ attribute :x, :string
293
+
294
+ json do
295
+ map "x", to: :x, when_attribute: { "type" => 42 }
296
+ end
297
+ end
298
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
299
+ end
300
+
301
+ it "rejects unmatched without when_attribute" do
302
+ expect do
303
+ Class.new(Lutaml::Model::Serializable) do
304
+ attribute :x, :string
305
+
306
+ json do
307
+ map "x", to: :x, unmatched: :raise
308
+ end
309
+ end
310
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
311
+ end
312
+
313
+ it "rejects unknown policy values" do
314
+ expect do
315
+ Class.new(Lutaml::Model::Serializable) do
316
+ attribute :x, :string
317
+
318
+ json do
319
+ map "x", to: :x, when_attribute: { "type" => "a" },
320
+ unmatched: :explode
321
+ end
322
+ end
323
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /unmatched/)
324
+ end
325
+
326
+ it "rejects when_attribute combined with custom methods" do
327
+ expect do
328
+ Class.new(Lutaml::Model::Serializable) do
329
+ attribute :x, :string
330
+
331
+ json do
332
+ map "x", to: :x, when_attribute: { "type" => "a" },
333
+ with: { to: :x_to, from: :x_from }
334
+ end
335
+ end
336
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
337
+ end
338
+
339
+ it "survives deep_dup of mappings" do
340
+ rule = KvWhenRequirement.mappings_for(:json).mappings.find do |r|
341
+ r.to == :guidance
342
+ end
343
+
344
+ expect(rule.when_attribute).to eq("type" => "guidance")
345
+ expect(rule.deep_dup.when_attribute).to eq("type" => "guidance")
346
+ end
347
+ end
348
+ end