lutaml-model 0.8.43 → 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: fe2357c36c2a199a3788090dffd420874612482e21e7ac678bd5f1e6fdd575ce
4
- data.tar.gz: a3d601af07558b2915dce9f4df383c00b905505c8fbcd192a89ad88b3e66ea49
3
+ metadata.gz: 07e6ecf8e0047cd91862259a5a9db3b1cfb09122e752cdd17a4523d7169c89c5
4
+ data.tar.gz: 5fd97454986a0d0f4bd1f13d75af01d0a692618acd9fafcc702bfaea7a73827f
5
5
  SHA512:
6
- metadata.gz: c82bb3761127b4beb8f2d267a60d107d69eb509cd2fd094763eed5ab5d0814497252db4cd1311709072ab829b156aac46f839852bcc4f20f37e627aa7b5daea7
7
- data.tar.gz: 32aa679d229c03ccccf0ee8f1359f9559309b92f5493a0207fd5a495e0f8ef1a0cc3255a0e4e30c2e7c5319e8910a8863286116d6a0e4bfe72af1a0b8be2c71f
6
+ metadata.gz: d95d9d885d0fbc63200900751b75c62282b63970f3fe4ebb1707ff134452bd57d714b8aad22f2253b70ea9509d1456dfd6bbee2a43f3cb292de8ae87959ab617
7
+ data.tar.gz: 455d7af1865469474d7f11f623dede9fbd65311dabaa30c0546a0193bdfbab4e99356b50ca6e8ffa2deb5f108ca0d5e2de9bf112e03ce607328c9ff4e6de0a8d
@@ -592,5 +592,56 @@ writes that attribute).
592
592
 
593
593
  Co-occurrence semantics: several `when_attribute` rules may share the
594
594
  element name; each target attribute fills independently, so declare each
595
- as `collection: true` when the element repeats. Rules without
596
- `when_attribute` keep claiming every occurrence of their name as before.
595
+ as `collection: true` when the element repeats. A rule without
596
+ `when_attribute` that shares the name only claims occurrences no
597
+ discriminator rule claimed, so every occurrence is captured exactly once.
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
+
616
+ `when_attribute` and `polymorphic:` are two different dispatch axes over
617
+ the same discriminator idea — pick by what the discriminator selects:
618
+
619
+ * `polymorphic: { attribute:, class_map: }` — the value selects the
620
+ **class** each occurrence hydrates into; ONE attribute holds every
621
+ occurrence. See the polymorphic models tutorial.
622
+ * `when_attribute:` — the value selects **which attribute** claims the
623
+ occurrence; the declared type stays fixed.
624
+
625
+ They compose: a rule may partition with `when_attribute` while its
626
+ attribute dispatches classes with `polymorphic`.
627
+
628
+ Under an `ordered` mapping, serialization preserves the original
629
+ interleaving: `element_order` records each occurrence's attributes, and
630
+ the ordered walk routes every occurrence back to the rule whose
631
+ discriminator it satisfies.
632
+
633
+ [source,ruby]
634
+ ----
635
+ xml do
636
+ element "requirement"
637
+ ordered
638
+ map_element "component", to: :guidance,
639
+ when_attribute: { "type" => "guidance" }
640
+ map_element "component", to: :purpose,
641
+ when_attribute: { "type" => "purpose" }
642
+ end
643
+ ----
644
+
645
+ Given `<component type="guidance">`, `<component type="purpose">`,
646
+ `<component type="guidance">` in the source, `to_xml` re-emits exactly
647
+ that guidance, purpose, guidance sequence.
@@ -9,6 +9,8 @@ nav_order: 8
9
9
 
10
10
  Polymorphic attributes allow an attribute to accept multiple types of values. This is useful when attributes share common characteristics but have type-specific behaviors.
11
11
 
12
+ NOTE: Polymorphism dispatches the *class* of each hydrated item into one attribute. If the discriminator instead needs to route occurrences of a shared element name into *different attributes* (e.g. `component[@type='guidance']` → `guidance`, `component[@type='purpose']` → `purpose`), that is `when_attribute:` in the advanced mapping guide — the two compose on one rule.
13
+
12
14
  == Understanding polymorphism
13
15
 
14
16
  A polymorphic attribute can accept instances of different classes that share a common base class.
@@ -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.43"
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"
@@ -302,7 +302,8 @@ module Lutaml
302
302
  Lutaml::Xml::Element.new("Element", child.unprefixed_name,
303
303
  node_type: :element,
304
304
  namespace_uri: child.namespace_uri,
305
- namespace_prefix: child.namespace_prefix)
305
+ namespace_prefix: child.namespace_prefix,
306
+ attributes: order_entry_attributes(child))
306
307
  end
307
308
  end
308
309
  end.each(&:freeze).freeze
@@ -4,7 +4,7 @@ module Lutaml
4
4
  include Lutaml::Model::Liquefiable
5
5
 
6
6
  attr_reader :type, :name, :text_content, :node_type, :namespace_uri,
7
- :namespace_prefix
7
+ :namespace_prefix, :attributes
8
8
 
9
9
  # Create a new Element for order tracking
10
10
  #
@@ -14,8 +14,13 @@ module Lutaml
14
14
  # @param node_type [Symbol, nil] The node type (:text, :cdata, :element, :comment, :processing_instruction)
15
15
  # @param namespace_uri [String, nil] The namespace URI of this element
16
16
  # @param namespace_prefix [String, nil] The namespace prefix of this element
17
+ # @param attributes [::Hash{String=>String}, nil] The element's
18
+ # attributes keyed by namespaced name. Only element entries parsed
19
+ # from a document carry them, so `when_attribute` rules can tell
20
+ # same-name entries apart on ordered serialization.
17
21
  def initialize(type, name, text_content: nil, node_type: nil,
18
- namespace_uri: nil, namespace_prefix: nil)
22
+ namespace_uri: nil, namespace_prefix: nil,
23
+ attributes: nil)
19
24
  @type = type # "Text" or "Element" - deprecated, kept for backward compatibility
20
25
  @name = name
21
26
  # Infer node_type from type for backward compatibility if not provided
@@ -26,6 +31,7 @@ module Lutaml
26
31
  @text_content ||= name if text? || cdata?
27
32
  @namespace_uri = namespace_uri
28
33
  @namespace_prefix = namespace_prefix
34
+ @attributes = attributes&.freeze
29
35
  end
30
36
 
31
37
  # Check if this is a text content node (not CDATA)
@@ -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
@@ -1053,11 +1082,27 @@ _effective_register)
1053
1082
 
1054
1083
  # lutaml-model#88: attribute-value discriminator — among the
1055
1084
  # name-matched occurrences, keep only those whose sibling
1056
- # attributes carry the expected values.
1085
+ # attributes carry the expected values. A plain rule sharing the
1086
+ # name with discriminator rules gets the complement — occurrences
1087
+ # no discriminator claimed — so each occurrence is captured
1088
+ # exactly once, mirroring ordered-serialization routing.
1057
1089
  if rule.when_attribute?
1090
+ if rule.unmatched == :raise
1091
+ check_unmatched_discriminators!(children, rule, rule_names, session)
1092
+ end
1058
1093
  children = children.select do |child|
1059
1094
  rule.matches_when_attribute?(child)
1060
1095
  end
1096
+ else
1097
+ siblings = session.when_attribute_siblings_by_name
1098
+ unless siblings.empty?
1099
+ claimed = rule_names.filter_map { |n| siblings[n] }.flatten
1100
+ unless claimed.empty?
1101
+ children = children.reject do |child|
1102
+ claimed.any? { |sibling| sibling.matches_when_attribute?(child) }
1103
+ end
1104
+ end
1105
+ end
1061
1106
  end
1062
1107
 
1063
1108
  # Performance: Cache rule method check before loop to avoid repeated method calls
@@ -65,6 +65,50 @@ module Lutaml
65
65
  end
66
66
  end
67
67
 
68
+ # lutaml-model#88: element wire names partitioned by `when_attribute`
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 ||=
84
+ if instance_is_serialize && (mapping = xml_mapping)
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
90
+
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
97
+ end
98
+ [siblings, plain_names]
99
+ else
100
+ [{}, {}]
101
+ end
102
+ end
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
+
68
112
  def model_class
69
113
  @model_class ||= instance.class
70
114
  end
@@ -220,25 +220,40 @@ module Lutaml
220
220
  # accepts a nil rule namespace, namespace aliases and name aliases.
221
221
  # When more than one rule would claim the entry, reconciling would
222
222
  # feed the value to the wrong rule, so leave the rule alone.
223
+ # Rules distinguished by `when_attribute` are separate rules: their
224
+ # inserted entries carry the discriminator attributes, so they
225
+ # resolve back correctly.
223
226
  def unambiguous?(rule, element_rules)
227
+ discriminator = rule_discriminator(rule)
224
228
  matches = element_rules.select do |candidate|
225
229
  matches_element_rule?(candidate, rule.serialized_name,
226
- rule.namespace_class&.uri)
230
+ rule.namespace_class&.uri) &&
231
+ rule_discriminator(candidate) == discriminator
227
232
  end
228
233
 
229
234
  matches == [rule]
230
235
  end
231
236
 
232
237
  def new_order_entry(rule)
238
+ discriminator = rule_discriminator(rule)
233
239
  ::Lutaml::Xml::Element.new(
234
240
  "Element",
235
241
  rule.serialized_name,
236
242
  node_type: :element,
237
243
  namespace_uri: rule.namespace_class&.uri,
238
244
  namespace_prefix: nil,
245
+ attributes: discriminator && discriminator_attributes(discriminator),
239
246
  )
240
247
  end
241
248
 
249
+ # The discriminator pairs as order-entry attributes, so an inserted
250
+ # entry routes back to its own rule at emit time and re-parses.
251
+ def discriminator_attributes(pairs)
252
+ pairs.each_with_object({}) do |(name, value), attrs|
253
+ attrs[name.to_s] = value.to_s
254
+ end
255
+ end
256
+
242
257
  # Where a rule's new entries go:
243
258
  # - after the last entry that already resolves to it, so extra
244
259
  # collection items follow the existing ones and any interleaved
@@ -122,6 +122,14 @@ model_class, register_id)
122
122
 
123
123
  # Find the mapping rule for an element from element_order
124
124
  #
125
+ # Same-name entries under different `when_attribute` discriminators
126
+ # (all `<component>`s, distinguished by `type="guidance"` vs
127
+ # `type="purpose"`) route to the rule whose discriminator pairs the
128
+ # entry's recorded attributes satisfy. Entries carrying no
129
+ # attributes — built before the entry format carried them, or
130
+ # inserted by hand — fall back to the first plain rule, which is
131
+ # the pre-discriminator behavior.
132
+ #
125
133
  # @param object [Xml::Element] Element from element_order
126
134
  # @param compiled_rules [Array<CompiledRule>] The compiled rules
127
135
  # @return [CompiledRule, nil] The matching rule or nil
@@ -131,11 +139,23 @@ model_class, register_id)
131
139
 
132
140
  object_ns_uri = object.namespace_uri # nil if old element_order (backward compat)
133
141
 
134
- compiled_rules.find do |r|
135
- r.is_a?(::Lutaml::Model::CompiledRule) &&
142
+ first = nil
143
+ shared_name = false
144
+ compiled_rules.each do |r|
145
+ next unless r.is_a?(::Lutaml::Model::CompiledRule) &&
136
146
  r.option(:mapping_type) == :element &&
137
147
  matches_element_rule?(r, object.name, object_ns_uri)
148
+
149
+ if first.nil?
150
+ first = r
151
+ else
152
+ shared_name = true
153
+ break
154
+ end
138
155
  end
156
+ return first unless shared_name
157
+
158
+ resolve_shared_name_rule(object, compiled_rules, object_ns_uri)
139
159
  end
140
160
 
141
161
  private
@@ -175,6 +195,57 @@ model_class, register_id)
175
195
  rule.matches_name?(name) && rule_ns_matches
176
196
  end
177
197
 
198
+ # Pick among rules sharing a serialized name when the entry's
199
+ # recorded attributes are the only thing that tells them apart.
200
+ #
201
+ # A discriminator rule whose pairs the entry satisfies wins over
202
+ # any plain rule regardless of declaration order, because a plain
203
+ # rule would claim EVERY same-name entry including discriminated
204
+ # ones. Entries matching no discriminator pair — an unknown value,
205
+ # or an entry without attributes — fall back to the first plain
206
+ # rule.
207
+ def resolve_shared_name_rule(object, compiled_rules, object_ns_uri)
208
+ plain = nil
209
+ compiled_rules.each do |r|
210
+ next unless r.is_a?(::Lutaml::Model::CompiledRule) &&
211
+ r.option(:mapping_type) == :element &&
212
+ matches_element_rule?(r, object.name, object_ns_uri)
213
+
214
+ pairs = rule_discriminator(r)
215
+ return r if pairs && entry_matches_when_attribute?(object, pairs)
216
+
217
+ plain ||= r if pairs.nil?
218
+ end
219
+ plain
220
+ end
221
+
222
+ # The rule's `when_attribute` discriminator pairs, or nil for a
223
+ # plain rule. Shared by OrderedApplier and OrderReconciler since
224
+ # both run on the same transform host.
225
+ #
226
+ # @param rule [CompiledRule]
227
+ # @return [::Hash, nil]
228
+ def rule_discriminator(rule)
229
+ pairs = rule.option(:when_attribute)
230
+ pairs && !pairs.empty? ? pairs : nil
231
+ end
232
+
233
+ # Whether an order entry's recorded attributes satisfy a rule's
234
+ # discriminator pairs. Mirrors Xml::MappingRule#matches_when_attribute?
235
+ # — non-nil and string-equal — against the namespaced-name keying
236
+ # the entries record.
237
+ def entry_matches_when_attribute?(object, pairs)
238
+ return false unless object.is_a?(::Lutaml::Xml::Element)
239
+
240
+ attrs = object.attributes
241
+ return false if attrs.nil?
242
+
243
+ pairs.all? do |name, expected|
244
+ actual = attrs[name.to_s]
245
+ !actual.nil? && actual.to_s == expected.to_s
246
+ end
247
+ end
248
+
178
249
  def process_element_order_item(object, root, model_instance, options,
179
250
  compiled_rules, _mapping, element_indices,
180
251
  content_rule = nil, text_node_index = 0,
@@ -284,11 +284,30 @@ module Lutaml
284
284
  Lutaml::Xml::Element.new("Element", child.unprefixed_name,
285
285
  node_type: :element,
286
286
  namespace_uri: child.namespace_uri,
287
- namespace_prefix: child.namespace_prefix)
287
+ namespace_prefix: child.namespace_prefix,
288
+ attributes: order_entry_attributes(child))
288
289
  end
289
290
  end.each(&:freeze).freeze
290
291
  end
291
292
 
293
+ # The attributes an element-order entry records, keyed by namespaced
294
+ # name — the same key find_attribute_value indexes. Same-name entries
295
+ # under different `when_attribute` discriminators need them to route
296
+ # back to the right rule on ordered serialization. nil for attribute-
297
+ # less children keeps the common entry allocation-free.
298
+ #
299
+ # @param child [XmlElement] An element child of this node
300
+ # @return [::Hash{String=>String}, nil]
301
+ def order_entry_attributes(child)
302
+ return if child.attributes.empty?
303
+
304
+ attrs = {}
305
+ child.attributes.each_value do |attr|
306
+ attrs[attr.namespaced_name] = attr.value
307
+ end
308
+ attrs.freeze
309
+ end
310
+
292
311
  def root
293
312
  self
294
313
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: `when_attribute` partitioning under `ordered` mappings.
6
+ # The ordered walk routes each element_order entry by its recorded
7
+ # attributes, so `<component type="guidance">` and `<component
8
+ # type="purpose">` interleave on to_xml exactly as the source document did.
9
+ class WhenOrderedComponent < 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 WhenOrderedRequirement < Lutaml::Model::Serializable
19
+ attribute :guidance, WhenOrderedComponent, collection: true
20
+ attribute :purpose, WhenOrderedComponent, collection: true
21
+ attribute :test_method, WhenOrderedComponent, collection: true
22
+
23
+ xml do
24
+ element "requirement"
25
+ ordered
26
+ map_element "component", to: :guidance,
27
+ when_attribute: { "type" => "guidance" }
28
+ map_element "component", to: :purpose,
29
+ when_attribute: { "type" => "purpose" }
30
+ map_element "component", to: :test_method,
31
+ when_attribute: { type: "test-method" }
32
+ end
33
+ end
34
+
35
+ RSpec.describe "when_attribute under ordered mappings" do
36
+ before do
37
+ stub_const("WhenOrdered::Component", WhenOrderedComponent)
38
+ stub_const("WhenOrdered::Requirement", WhenOrderedRequirement)
39
+ end
40
+
41
+ let(:xml) do
42
+ <<~XML
43
+ <requirement>
44
+ <component type="guidance"><text>g1</text></component>
45
+ <component type="purpose"><text>p1</text></component>
46
+ <component type="guidance"><text>g2</text></component>
47
+ <component type="test-method"><text>t1</text></component>
48
+ </requirement>
49
+ XML
50
+ end
51
+
52
+ def serialized_types(doc)
53
+ doc.scan(/<component type="([^"]+)">/).flatten
54
+ end
55
+
56
+ it "parses partitioned values" do
57
+ req = WhenOrderedRequirement.from_xml(xml)
58
+
59
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
60
+ expect(req.purpose.map(&:text)).to eq(["p1"])
61
+ expect(req.test_method.map(&:text)).to eq(["t1"])
62
+ end
63
+
64
+ it "records the discriminator attributes on element_order entries" do
65
+ req = WhenOrderedRequirement.from_xml(xml)
66
+ entries = req.element_order.select { |e| e.type == "Element" }
67
+
68
+ expect(entries.map(&:attributes)).to eq([
69
+ { "type" => "guidance" },
70
+ { "type" => "purpose" },
71
+ { "type" => "guidance" },
72
+ { "type" => "test-method" },
73
+ ])
74
+ end
75
+
76
+ it "round-trips the original interleaving" do
77
+ round = WhenOrderedRequirement.from_xml(xml).to_xml
78
+
79
+ expect(serialized_types(round)).to eq(
80
+ %w[guidance purpose guidance test-method],
81
+ )
82
+ end
83
+
84
+ it "survives a re-parse of the ordered output" do
85
+ once = WhenOrderedRequirement.from_xml(xml)
86
+ twice = WhenOrderedRequirement.from_xml(once.to_xml)
87
+
88
+ expect(twice.guidance.map(&:text)).to eq(%w[g1 g2])
89
+ expect(twice.purpose.map(&:text)).to eq(["p1"])
90
+ expect(twice.test_method.map(&:text)).to eq(["t1"])
91
+ end
92
+
93
+ it "routes appended items to the right attribute on ordered output" do
94
+ req = WhenOrderedRequirement.from_xml(xml)
95
+ req.guidance << WhenOrderedComponent.new(text: "g3")
96
+
97
+ out = req.to_xml
98
+ # Reconciliation places new entries after the rule's last existing
99
+ # occurrence (the OrderReconciler contract), not at document end.
100
+ expect(serialized_types(out)).to eq(
101
+ %w[guidance purpose guidance guidance test-method],
102
+ )
103
+
104
+ reparsed = WhenOrderedRequirement.from_xml(out)
105
+ expect(reparsed.guidance.map(&:text)).to eq(%w[g1 g2 g3])
106
+ expect(reparsed.purpose.map(&:text)).to eq(["p1"])
107
+ end
108
+
109
+ it "drops occurrences with an unknown discriminator from ordered output" do
110
+ with_unknown = WhenOrderedRequirement.from_xml(<<~XML)
111
+ <requirement>
112
+ <component type="guidance"><text>g1</text></component>
113
+ <component type="unknown"><text>x</text></component>
114
+ <component type="purpose"><text>p1</text></component>
115
+ </requirement>
116
+ XML
117
+
118
+ expect(with_unknown.guidance.map(&:text)).to eq(["g1"])
119
+ expect(with_unknown.purpose.map(&:text)).to eq(["p1"])
120
+ expect(serialized_types(with_unknown.to_xml)).to eq(%w[guidance purpose])
121
+ end
122
+
123
+ it "falls back to the plain rule for legacy attribute-less entries" do
124
+ both = Class.new(Lutaml::Model::Serializable) do
125
+ attribute :plain, :string
126
+ attribute :special, :string
127
+
128
+ xml do
129
+ element "holder"
130
+ ordered
131
+ map_element "item", to: :plain
132
+ map_element "item", to: :special, when_attribute: { "kind" => "special" }
133
+ end
134
+ end
135
+ stub_const("WhenOrdered::Holder", both)
136
+
137
+ doc = both.from_xml(
138
+ "<holder><item>a</item><item kind=\"special\">b</item></holder>",
139
+ )
140
+
141
+ expect(doc.plain).to eq("a")
142
+ expect(doc.special).to eq("b")
143
+
144
+ out = doc.to_xml
145
+ expect(out.scan(%r{<item[^>]*>([^<]*)</item>})).to eq([["a"], ["b"]])
146
+ expect(out).to include('kind="special"')
147
+ end
148
+ end
@@ -130,4 +130,96 @@ RSpec.describe "when_attribute discriminator mappings" do
130
130
  end
131
131
  end.to raise_error(ArgumentError)
132
132
  end
133
+
134
+ # Orthogonality guard: `when_attribute` partitions occurrences across
135
+ # attributes; `polymorphic` dispatches the class of each hydrated item.
136
+ # Different axes over the same discriminator concept — they compose on
137
+ # one rule rather than substitute for each other.
138
+ describe "vs polymorphic dispatch" do
139
+ before do
140
+ stub_const("AxisComp", Class.new(Lutaml::Model::Serializable) do
141
+ attribute :text, :string
142
+ end)
143
+ stub_const("AxisGuidanceComp", Class.new(AxisComp))
144
+ stub_const("AxisPurposeComp", Class.new(AxisComp))
145
+ end
146
+
147
+ let(:axes_xml) do
148
+ <<~XML
149
+ <req>
150
+ <component type="guidance"><text>g1</text></component>
151
+ <component type="purpose"><text>p1</text></component>
152
+ <component type="guidance"><text>g2</text></component>
153
+ </req>
154
+ XML
155
+ end
156
+
157
+ it "polymorphic keeps one attribute and dispatches classes" do
158
+ poly = Class.new(Lutaml::Model::Serializable) do
159
+ attribute :components, AxisComp, collection: true
160
+
161
+ xml do
162
+ element "req"
163
+ map_element "component", to: :components, polymorphic: {
164
+ attribute: "type",
165
+ class_map: {
166
+ "guidance" => "AxisGuidanceComp",
167
+ "purpose" => "AxisPurposeComp",
168
+ },
169
+ }
170
+ end
171
+ end
172
+
173
+ parsed = poly.from_xml(axes_xml)
174
+ expect(parsed.components.map(&:class))
175
+ .to eq([AxisGuidanceComp, AxisPurposeComp, AxisGuidanceComp])
176
+ end
177
+
178
+ it "when_attribute keeps one class and partitions attributes" do
179
+ partitioned = Class.new(Lutaml::Model::Serializable) do
180
+ attribute :guidance, AxisComp, collection: true
181
+ attribute :purpose, AxisComp, collection: true
182
+
183
+ xml do
184
+ element "req"
185
+ map_element "component", to: :guidance,
186
+ when_attribute: { "type" => "guidance" }
187
+ map_element "component", to: :purpose,
188
+ when_attribute: { "type" => "purpose" }
189
+ end
190
+ end
191
+
192
+ parsed = partitioned.from_xml(axes_xml)
193
+ expect(parsed.guidance.map(&:text)).to eq(%w[g1 g2])
194
+ expect(parsed.purpose.map(&:text)).to eq(["p1"])
195
+ expect(parsed.guidance.first.class).to eq(AxisComp)
196
+ end
197
+
198
+ it "composes: partition and class dispatch on the same rule" do
199
+ both = Class.new(Lutaml::Model::Serializable) do
200
+ attribute :guidance, AxisComp, collection: true
201
+ attribute :purpose, AxisComp, collection: true
202
+
203
+ xml do
204
+ element "req"
205
+ map_element "component", to: :guidance,
206
+ when_attribute: { "type" => "guidance" },
207
+ polymorphic: {
208
+ attribute: "type",
209
+ class_map: { "guidance" => "AxisGuidanceComp" },
210
+ }
211
+ map_element "component", to: :purpose,
212
+ when_attribute: { "type" => "purpose" },
213
+ polymorphic: {
214
+ attribute: "type",
215
+ class_map: { "purpose" => "AxisPurposeComp" },
216
+ }
217
+ end
218
+ end
219
+
220
+ parsed = both.from_xml(axes_xml)
221
+ expect(parsed.guidance.map(&:class)).to eq([AxisGuidanceComp, AxisGuidanceComp])
222
+ expect(parsed.purpose.map(&:class)).to eq([AxisPurposeComp])
223
+ end
224
+ end
133
225
  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.43
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
@@ -2006,7 +2007,9 @@ files:
2006
2007
  - spec/lutaml/xml/validate_xml_with_constructs_spec.rb
2007
2008
  - spec/lutaml/xml/validate_xml_with_spec.rb
2008
2009
  - spec/lutaml/xml/w3c_types_spec.rb
2010
+ - spec/lutaml/xml/when_attribute_ordered_spec.rb
2009
2011
  - spec/lutaml/xml/when_attribute_spec.rb
2012
+ - spec/lutaml/xml/when_attribute_unmatched_spec.rb
2010
2013
  - spec/lutaml/xml/xml_adapter_spec.rb
2011
2014
  - spec/lutaml/xml/xml_declaration_spec.rb
2012
2015
  - spec/lutaml/xml/xml_element_guard_spec.rb