lutaml-model 0.8.44 → 0.8.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57902ec64a7b7915f601d0525d747ec742c78a18bd7e78469c0af41aa35d2101
4
- data.tar.gz: a27ee76ad1f66aaacb324c5ba79f75dca698c3f729c2e61706ed4cbe85c0b255
3
+ metadata.gz: 07e6ecf8e0047cd91862259a5a9db3b1cfb09122e752cdd17a4523d7169c89c5
4
+ data.tar.gz: 5fd97454986a0d0f4bd1f13d75af01d0a692618acd9fafcc702bfaea7a73827f
5
5
  SHA512:
6
- metadata.gz: fd6d918fcd42d655682bc769f9c047860a3bba698258137f638cafd789f7b8ee0e176c89a12c84f09310245aaa227bc33d3c344c255591b14527a691acca6088
7
- data.tar.gz: 36bf72d194bc8ed3e5ddb8837c55f5dd3170006ecf37cfc26004f46133fc6684e267d56123577e06b9ca9fe2d3dfcab838a21b6bd3b884409613ea364276329e
6
+ metadata.gz: d95d9d885d0fbc63200900751b75c62282b63970f3fe4ebb1707ff134452bd57d714b8aad22f2253b70ea9509d1456dfd6bbee2a43f3cb292de8ae87959ab617
7
+ data.tar.gz: 455d7af1865469474d7f11f623dede9fbd65311dabaa30c0546a0193bdfbab4e99356b50ca6e8ffa2deb5f108ca0d5e2de9bf112e03ce607328c9ff4e6de0a8d
@@ -596,6 +596,23 @@ as `collection: true` when the element repeats. A rule without
596
596
  `when_attribute` that shares the name only claims occurrences no
597
597
  discriminator rule claimed, so every occurrence is captured exactly once.
598
598
 
599
+ By default an occurrence no rule claims — an uncovered discriminator
600
+ value, or a missing discriminator attribute — is silently dropped.
601
+ Declare `unmatched: :raise` on a discriminator rule to fail the parse
602
+ instead: `Lutaml::Model::UnknownDiscriminatorError` names the element and
603
+ the unclaimed discriminator values. A plain rule sharing the name claims
604
+ everything left, so the strict policy never fires when one is present.
605
+
606
+ [source,ruby]
607
+ ----
608
+ xml do
609
+ element "requirement"
610
+ map_element "component", to: :guidance,
611
+ when_attribute: { "type" => "guidance" },
612
+ unmatched: :raise
613
+ end
614
+ ----
615
+
599
616
  `when_attribute` and `polymorphic:` are two different dispatch axes over
600
617
  the same discriminator idea — pick by what the discriminator selects:
601
618
 
@@ -283,6 +283,7 @@ require "lutaml/model/error/type_only_namespace_error"
283
283
  require "lutaml/model/error/undefined_attribute_error"
284
284
  require "lutaml/model/error/union_schema_unsupported_error"
285
285
  require "lutaml/model/error/unknown_adapter_type_error"
286
+ require "lutaml/model/error/unknown_discriminator_error"
286
287
  require "lutaml/model/error/unknown_sequence_mapping_error"
287
288
  require "lutaml/model/error/unknown_type_error"
288
289
  require "lutaml/model/error/unresolvable_type_error"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ # Raised when a `when_attribute` rule declared `unmatched: :raise`
6
+ # parses an occurrence that no rule claims: it matches no
7
+ # discriminator and no plain rule shares the wire name.
8
+ class UnknownDiscriminatorError < Error
9
+ end
10
+ end
11
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.44"
5
+ VERSION = "0.8.45"
6
6
  end
7
7
  end
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
@@ -625,6 +628,21 @@ module Lutaml
625
628
  end
626
629
  end
627
630
 
631
+ # lutaml-model#88: what a parse does with an occurrence that no
632
+ # rule on the wire name claims — :drop it (default) or :raise
633
+ # UnknownDiscriminatorError. Only meaningful on discriminator rules.
634
+ def validate_unmatched!(unmatched, when_attribute)
635
+ unless %i[drop raise].include?(unmatched)
636
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
637
+ "unmatched expects :drop or :raise, got #{unmatched.inspect}"
638
+ end
639
+
640
+ return unless when_attribute.empty? && unmatched != :drop
641
+
642
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
643
+ "unmatched only applies to rules declared with when_attribute"
644
+ end
645
+
628
646
  def map_attribute(
629
647
  name,
630
648
  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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: `unmatched: :raise` on a when_attribute rule fails the
6
+ # parse when an occurrence is claimed by no rule — no discriminator match
7
+ # and no plain sibling on the wire name. The default (:drop) keeps the
8
+ # silent-drop behavior.
9
+ class UnmatchedComponent < Lutaml::Model::Serializable
10
+ attribute :text, :string
11
+
12
+ xml do
13
+ element "component"
14
+ map_element "text", to: :text
15
+ end
16
+ end
17
+
18
+ class UnmatchedRequirement < Lutaml::Model::Serializable
19
+ attribute :guidance, UnmatchedComponent, collection: true
20
+ attribute :purpose, UnmatchedComponent, collection: true
21
+
22
+ xml do
23
+ element "requirement"
24
+ map_element "component", to: :guidance,
25
+ when_attribute: { "type" => "guidance" },
26
+ unmatched: :raise
27
+ map_element "component", to: :purpose,
28
+ when_attribute: { "type" => "purpose" }
29
+ end
30
+ end
31
+
32
+ RSpec.describe "when_attribute unmatched policy" do
33
+ before do
34
+ stub_const("Unmatched::Component", UnmatchedComponent)
35
+ stub_const("Unmatched::Requirement", UnmatchedRequirement)
36
+ end
37
+
38
+ it "parses when every occurrence is covered" do
39
+ req = UnmatchedRequirement.from_xml(<<~XML)
40
+ <requirement>
41
+ <component type="guidance"><text>g1</text></component>
42
+ <component type="purpose"><text>p1</text></component>
43
+ </requirement>
44
+ XML
45
+
46
+ expect(req.guidance.map(&:text)).to eq(["g1"])
47
+ expect(req.purpose.map(&:text)).to eq(["p1"])
48
+ end
49
+
50
+ it "raises on an unknown discriminator value" do
51
+ xml = <<~XML
52
+ <requirement>
53
+ <component type="guidance"><text>g1</text></component>
54
+ <component type="unknown"><text>x</text></component>
55
+ </requirement>
56
+ XML
57
+
58
+ expect { UnmatchedRequirement.from_xml(xml) }
59
+ .to raise_error(Lutaml::Model::UnknownDiscriminatorError, /type="unknown"/)
60
+ end
61
+
62
+ it "raises when the discriminator attribute is missing entirely" do
63
+ xml = "<requirement><component><text>x</text></component></requirement>"
64
+
65
+ expect { UnmatchedRequirement.from_xml(xml) }
66
+ .to raise_error(Lutaml::Model::UnknownDiscriminatorError, /<component>/)
67
+ end
68
+
69
+ it "names the tested attributes of the unclaimed occurrence" do
70
+ xml = '<requirement><component type="later"><text>x</text></component></requirement>'
71
+
72
+ expect { UnmatchedRequirement.from_xml(xml) }
73
+ .to raise_error(Lutaml::Model::UnknownDiscriminatorError, /unmatched: :drop/)
74
+ end
75
+
76
+ it "does not raise when a plain rule claims the leftovers" do
77
+ both = Class.new(Lutaml::Model::Serializable) do
78
+ attribute :special, UnmatchedComponent, collection: true
79
+ attribute :plain, UnmatchedComponent, collection: true
80
+
81
+ xml do
82
+ element "holder"
83
+ map_element "item", to: :special,
84
+ when_attribute: { "kind" => "special" },
85
+ unmatched: :raise
86
+ map_element "item", to: :plain
87
+ end
88
+ end
89
+ stub_const("Unmatched::Holder", both)
90
+
91
+ doc = both.from_xml(
92
+ '<holder><item kind="special"><text>b</text></item><item><text>a</text></item></holder>',
93
+ )
94
+
95
+ expect(doc.special.map(&:text)).to eq(["b"])
96
+ expect(doc.plain.map(&:text)).to eq(["a"])
97
+ end
98
+
99
+ it "drops unmatched occurrences by default" do
100
+ lenient = Class.new(Lutaml::Model::Serializable) do
101
+ attribute :guidance, UnmatchedComponent, collection: true
102
+
103
+ xml do
104
+ element "requirement"
105
+ map_element "component", to: :guidance,
106
+ when_attribute: { "type" => "guidance" }
107
+ end
108
+ end
109
+ stub_const("Unmatched::Lenient", lenient)
110
+
111
+ doc = lenient.from_xml(
112
+ '<requirement><component type="guidance"><text>g</text></component>' \
113
+ '<component type="other"><text>x</text></component></requirement>',
114
+ )
115
+ expect(doc.guidance.map(&:text)).to eq(["g"])
116
+ end
117
+
118
+ it "survives deep_dup of mappings" do
119
+ rule = UnmatchedRequirement.mappings_for(:xml).mappings.find do |r|
120
+ r.to == :guidance
121
+ end
122
+
123
+ expect(rule.unmatched).to eq(:raise)
124
+ duped = rule.deep_dup
125
+ expect(duped.unmatched).to eq(:raise)
126
+ expect(duped.when_attribute).to eq("type" => "guidance")
127
+ end
128
+
129
+ it "rejects unknown policy values" do
130
+ expect do
131
+ Class.new(Lutaml::Model::Serializable) do
132
+ attribute :x, :string
133
+
134
+ xml do
135
+ element "x"
136
+ map_element "x", to: :x,
137
+ when_attribute: { "type" => "a" },
138
+ unmatched: :explode
139
+ end
140
+ end
141
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /unmatched/)
142
+ end
143
+
144
+ it "rejects unmatched without when_attribute" do
145
+ expect do
146
+ Class.new(Lutaml::Model::Serializable) do
147
+ attribute :x, :string
148
+
149
+ xml do
150
+ element "x"
151
+ map_element "x", to: :x, unmatched: :raise
152
+ end
153
+ end
154
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
155
+ end
156
+
157
+ it "is rejected on attribute mappings" do
158
+ expect do
159
+ Class.new(Lutaml::Model::Serializable) do
160
+ attribute :x, :string
161
+
162
+ xml do
163
+ element "x"
164
+ map_attribute "x", to: :x, unmatched: :raise
165
+ end
166
+ end
167
+ end.to raise_error(ArgumentError)
168
+ end
169
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.44
4
+ version: 0.8.45
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -489,6 +489,7 @@ files:
489
489
  - lib/lutaml/model/error/undefined_attribute_error.rb
490
490
  - lib/lutaml/model/error/union_schema_unsupported_error.rb
491
491
  - lib/lutaml/model/error/unknown_adapter_type_error.rb
492
+ - lib/lutaml/model/error/unknown_discriminator_error.rb
492
493
  - lib/lutaml/model/error/unknown_sequence_mapping_error.rb
493
494
  - lib/lutaml/model/error/unknown_type_error.rb
494
495
  - lib/lutaml/model/error/unresolvable_type_error.rb
@@ -2008,6 +2009,7 @@ files:
2008
2009
  - spec/lutaml/xml/w3c_types_spec.rb
2009
2010
  - spec/lutaml/xml/when_attribute_ordered_spec.rb
2010
2011
  - spec/lutaml/xml/when_attribute_spec.rb
2012
+ - spec/lutaml/xml/when_attribute_unmatched_spec.rb
2011
2013
  - spec/lutaml/xml/xml_adapter_spec.rb
2012
2014
  - spec/lutaml/xml/xml_declaration_spec.rb
2013
2015
  - spec/lutaml/xml/xml_element_guard_spec.rb