lutaml-model 0.8.42 → 0.8.43

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: 1cd747883eee7ea6a87aea0c708e6054f1f6cf9f7b06e295b32f1b7ba06a8a75
4
- data.tar.gz: 639b21ad4dd77104b3bce40f0ba7bad925a5b608806e6e8979b50cb68f57b70e
3
+ metadata.gz: fe2357c36c2a199a3788090dffd420874612482e21e7ac678bd5f1e6fdd575ce
4
+ data.tar.gz: a3d601af07558b2915dce9f4df383c00b905505c8fbcd192a89ad88b3e66ea49
5
5
  SHA512:
6
- metadata.gz: 05174a52193320261f496e92ac656b6ddc5a47167db5b98b3682c0397a4c9fb0f2a58f20e8be689d7f35486bea63dc2228d367574c3834587be6d5ba97306cd9
7
- data.tar.gz: a6efef58f862390afe6fcf903ce3ff4d829d451079b7a1762990ed0e0bf1c5623c798c712a485d8e81cdc415189a37e9c5f7b1c02dd2d03ad6c17d96b5bac5a0
6
+ metadata.gz: c82bb3761127b4beb8f2d267a60d107d69eb509cd2fd094763eed5ab5d0814497252db4cd1311709072ab829b156aac46f839852bcc4f20f37e627aa7b5daea7
7
+ data.tar.gz: 32aa679d229c03ccccf0ee8f1359f9559309b92f5493a0207fd5a495e0f8ef1a0cc3255a0e4e30c2e7c5319e8910a8863286116d6a0e4bfe72af1a0b8be2c71f
@@ -562,3 +562,35 @@ end
562
562
  ====
563
563
 
564
564
 
565
+
566
+ === Attribute-value dispatch on a shared element name
567
+
568
+ When several distinct attributes share one wire element, selected by a
569
+ sibling attribute's value, declare the discriminator with `when_attribute:`
570
+
571
+ [source,ruby]
572
+ ----
573
+ class Requirement < Lutaml::Model::Serializable
574
+ attribute :guidance, Component, collection: true
575
+ attribute :purpose, Component, collection: true
576
+
577
+ xml do
578
+ element "requirement"
579
+ map_element "component", to: :guidance,
580
+ when_attribute: { "type" => "guidance" }
581
+ map_element "component", to: :purpose,
582
+ when_attribute: { "type" => "purpose" }
583
+ end
584
+ end
585
+ ----
586
+
587
+ Each rule hydrates only from the `<component>` occurrences whose `type`
588
+ attribute carries the expected value, in document order; uncovered values
589
+ are ignored by all discriminator rules. On serialization the discriminator
590
+ pair is re-emitted on the element (unless the value's own mapping already
591
+ writes that attribute).
592
+
593
+ Co-occurrence semantics: several `when_attribute` rules may share the
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.
@@ -78,7 +78,8 @@ module Lutaml
78
78
  polymorphic: {},
79
79
  polymorphic_map: {},
80
80
  transform: {},
81
- value_map: {}
81
+ value_map: {},
82
+ serialize: true
82
83
  )
83
84
  mapping_name = name_for_mapping(root_mappings, name)
84
85
  validate!(mapping_name, to, with, render_nil, render_empty)
@@ -100,6 +101,7 @@ module Lutaml
100
101
  polymorphic_map: polymorphic_map,
101
102
  transform: transform,
102
103
  value_map: value_map,
104
+ serialize: serialize,
103
105
  )
104
106
  end
105
107
 
@@ -22,7 +22,8 @@ module Lutaml
22
22
  polymorphic: {},
23
23
  polymorphic_map: {},
24
24
  transform: {},
25
- value_map: {}
25
+ value_map: {},
26
+ serialize: true
26
27
  )
27
28
  super(
28
29
  name,
@@ -41,6 +42,7 @@ module Lutaml
41
42
  polymorphic_map: polymorphic_map,
42
43
  transform: transform,
43
44
  value_map: value_map,
45
+ serialize: serialize,
44
46
  )
45
47
 
46
48
  @child_mappings = child_mappings
@@ -65,6 +67,7 @@ module Lutaml
65
67
  child_mappings: Lutaml::Model::Utils.deep_dup(child_mappings),
66
68
  root_mappings: Lutaml::Model::Utils.deep_dup(root_mappings),
67
69
  value_map: Lutaml::Model::Utils.deep_dup(@value_map),
70
+ serialize: serialize?,
68
71
  )
69
72
  end
70
73
 
@@ -127,6 +127,8 @@ module Lutaml
127
127
  end
128
128
 
129
129
  def valid_mapping?(rule, options)
130
+ return false if rule.respond_to?(:serialize?) && !rule.serialize?
131
+
130
132
  only = options[:only]
131
133
  except = options[:except]
132
134
  name = rule.to
@@ -230,6 +230,7 @@ transformation_factory:)
230
230
  custom_methods: custom_methods,
231
231
  delegate: delegate,
232
232
  root_mappings: mapping_rule.root_mappings,
233
+ serialize: mapping_rule.serialize?,
233
234
  )
234
235
  end
235
236
 
@@ -239,6 +240,8 @@ transformation_factory:)
239
240
  # @param options [Hash] Transformation options (may contain :only, :except)
240
241
  # @return [Boolean] true if the rule should be applied
241
242
  def valid_mapping?(rule, options)
243
+ return false if rule.option(:serialize) == false
244
+
242
245
  only = options[:only]
243
246
  except = options[:except]
244
247
  name = rule.attribute_name
@@ -61,7 +61,17 @@ module Lutaml
61
61
  # @param register [Symbol, nil] Register for custom-collection type resolution
62
62
  # @return [Object] A new collection instance
63
63
  def build_collection(*args, register: nil)
64
- items = args.flatten
64
+ # args.flatten allocated a fresh Array even for the dominant
65
+ # calls: no args, or one already-flat Array from cast_items
66
+ # (71,949 flatten allocations per ISO-13849 parse).
67
+ items = if args.empty?
68
+ []
69
+ elsif args.length == 1 && (first = args[0]).is_a?(::Array) &&
70
+ first.none?(::Array)
71
+ first
72
+ else
73
+ args.flatten
74
+ end
65
75
  unless register && initialize_accepts_register?
66
76
  return collection_class.new(items)
67
77
  end
@@ -70,7 +70,9 @@ module Lutaml
70
70
  polymorphic: {},
71
71
  polymorphic_map: {},
72
72
  transform: {},
73
- value_map: {}
73
+ value_map: {},
74
+ serialize: true,
75
+ when_attribute: {}
74
76
  )
75
77
  @name = name
76
78
  @to = to
@@ -92,6 +94,11 @@ module Lutaml
92
94
  @polymorphic = polymorphic
93
95
  @polymorphic_map = polymorphic_map
94
96
  @transform = transform
97
+ @serialize_mapping = serialize
98
+ # Attribute-value discriminator (lutaml-model#88): select this
99
+ # rule's wire occurrences by a sibling attribute value, e.g.
100
+ # `when_attribute: { "type" => "guidance" }`.
101
+ @when_attribute = (when_attribute || {}).freeze
95
102
 
96
103
  # Cache whether this rule needs the full deserialize chain.
97
104
  # Over 95% of rules are "simple" (no custom method, no delegate).
@@ -174,6 +181,11 @@ module Lutaml
174
181
 
175
182
  alias from name
176
183
  alias render_default? render_default
184
+
185
+ # Whether this rule participates in serialization output. False
186
+ # marks a hydrate-only mapping: consumed by from_* but never
187
+ # rendered by to_*. (The value callback #serialize is separate.)
188
+ def serialize? = @serialize_mapping
177
189
  alias attribute? attribute
178
190
 
179
191
  def render?(value, instance = nil, options = {})
@@ -328,6 +340,16 @@ context = nil)
328
340
  # each time (240k allocations on the ISO-13849 serialize profile).
329
341
  # Rules may be frozen, so no per-instance memo: one to_s instead
330
342
  # of two is the frozen-safe diet.
343
+ # Discriminator pairs this rule selects (and re-emits) on, empty
344
+ # for an ordinary rule.
345
+ def when_attribute
346
+ @when_attribute ||= {}.freeze
347
+ end
348
+
349
+ def when_attribute?
350
+ !when_attribute.empty?
351
+ end
352
+
331
353
  def custom_method_only?
332
354
  name = attribute_name.to_s
333
355
  (name.start_with?("__") && name.end_with?("__")) ||
@@ -133,8 +133,13 @@ module Lutaml
133
133
  return cached if cached
134
134
 
135
135
  ensure_imports!(register) if finalized?
136
- @merged_attributes_cache[register_id] ||=
137
- @attributes.merge(@register_records[register_id][:attributes])
136
+ # ensure_imports! may re-entrantly run clear_cache (restrict /
137
+ # import resolution), which nils @merged_attributes_cache —
138
+ # compute the merge first and re-materialize the memo store
139
+ # at write time (#815: standalone to_xml on register-bound
140
+ # models crashed with nil[] here).
141
+ merged = @attributes.merge(@register_records[register_id][:attributes])
142
+ (@merged_attributes_cache ||= {})[register_id] = merged
138
143
  else
139
144
  ensure_imports!(register) if finalized?
140
145
  @attributes
@@ -241,12 +241,21 @@ module Lutaml
241
241
  end
242
242
 
243
243
  def respond_to_missing?(method_name, include_private = false)
244
- (method_name.to_s.end_with?("=") && attribute_exist?(method_name)) ||
245
- super
244
+ # Setter probes from non-compiled dispatch land here ~24k times per
245
+ # ISO-13849 parse; strip the suffix once instead of re-walking
246
+ # attribute_exist?'s to_s/chomp/to_sym chain.
247
+ name = method_name.to_s
248
+ if name.end_with?("=") &&
249
+ self.class.attributes(lutaml_register)
250
+ .key?(name.delete_suffix("=").to_sym)
251
+ return true
252
+ end
253
+
254
+ super
246
255
  end
247
256
 
248
257
  def attribute_exist?(name)
249
- name = name.to_s.chomp("=").to_sym if name.end_with?("=")
258
+ name = name.to_s.delete_suffix("=").to_sym if name.end_with?("=")
250
259
 
251
260
  self.class.attributes(lutaml_register).key?(name)
252
261
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.42"
5
+ VERSION = "0.8.43"
6
6
  end
7
7
  end
@@ -527,6 +527,7 @@ module Lutaml
527
527
  nil),
528
528
  transform: {},
529
529
  value_map: {},
530
+ when_attribute: {},
530
531
  form: nil,
531
532
  documentation: nil,
532
533
  xsd_type: (xsd_type_provided = false
@@ -537,6 +538,8 @@ module Lutaml
537
538
  name, to, with, render_nil, render_empty, type: TYPES[:element]
538
539
  )
539
540
 
541
+ validate_when_attribute!(when_attribute) unless when_attribute.empty?
542
+
540
543
  # Warn if prefix parameter is provided
541
544
  if prefix_provided != false
542
545
  warn "[DEPRECATED] The prefix parameter on map_element is deprecated. " \
@@ -584,6 +587,7 @@ module Lutaml
584
587
  form: form,
585
588
  documentation: documentation,
586
589
  raw: raw,
590
+ when_attribute: when_attribute,
587
591
  )
588
592
  # Store rules with the same element name in an array to support
589
593
  # multiple mapping rules for the same element name with different target types
@@ -607,6 +611,20 @@ module Lutaml
607
611
  end
608
612
  end
609
613
 
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
+
610
628
  def map_attribute(
611
629
  name,
612
630
  to: nil,
@@ -1150,7 +1168,8 @@ module Lutaml
1150
1168
  grouped = {}
1151
1169
  mappings(reg_key).each do |r|
1152
1170
  next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
1153
- r.cdata || r.has_custom_method_for_deserialization? ||
1171
+ r.cdata || r.when_attribute? ||
1172
+ r.has_custom_method_for_deserialization? ||
1154
1173
  (r.transform.is_a?(Hash) && !r.transform.empty?) ||
1155
1174
  r.transform.is_a?(Class)
1156
1175
 
@@ -16,7 +16,7 @@ module Lutaml
16
16
  # Writers for deep_dup (preserves exact object references)
17
17
  attr_accessor :namespace, :prefix, :namespace_class
18
18
 
19
- def initialize(
19
+ def initialize( # rubocop:disable Metrics/ParameterLists -- mirrors the DSL surface
20
20
  name,
21
21
  to:,
22
22
  render_nil: false,
@@ -37,6 +37,7 @@ module Lutaml
37
37
  polymorphic_map: {},
38
38
  transform: {},
39
39
  value_map: {},
40
+ when_attribute: {},
40
41
  as_list: nil,
41
42
  delimiter: nil,
42
43
  form: nil,
@@ -59,6 +60,7 @@ module Lutaml
59
60
  polymorphic_map: polymorphic_map,
60
61
  transform: transform,
61
62
  value_map: value_map,
63
+ when_attribute: when_attribute,
62
64
  )
63
65
 
64
66
  # Store original namespace parameter to preserve :inherit symbol
@@ -225,6 +227,15 @@ module Lutaml
225
227
  end
226
228
  end
227
229
 
230
+ # Whether a parsed element satisfies this rule's when_attribute
231
+ # discriminator: every expected attribute value matches.
232
+ def matches_when_attribute?(element)
233
+ when_attribute.all? do |name, expected|
234
+ actual = element.find_attribute_value(name.to_s)
235
+ !actual.nil? && actual.to_s == expected.to_s
236
+ end
237
+ end
238
+
228
239
  def deep_dup
229
240
  # Preserve @namespace_param exactly as it was (string, Class, :inherit, or nil)
230
241
  # This ensures the duplicate has the same internal state as the original
@@ -261,6 +272,7 @@ module Lutaml
261
272
  form: @form,
262
273
  documentation: @documentation,
263
274
  raw: @raw,
275
+ when_attribute: when_attribute,
264
276
  ).tap do |dup_rule|
265
277
  # Manually preserve the exact @namespace_class object to avoid
266
278
  # recreating anonymous classes (which would have different object_ids)
@@ -40,6 +40,10 @@ module Lutaml
40
40
  # Performance: Frozen empty hash to reduce allocations
41
41
  EMPTY_HASH = {}.freeze
42
42
 
43
+ # ":".getbyte(0) — getbyte comparisons avoid the per-candidate
44
+ # string slice in namespace-prefix checks on the lenient paths.
45
+ COLON_BYTE = 58
46
+
43
47
  def data_to_model(data, _format, options = {})
44
48
  # Use child's own default register if it has one
45
49
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
@@ -600,6 +604,21 @@ _effective_register)
600
604
  def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
601
605
  return nil unless rule_name.include?(":")
602
606
 
607
+ # Pure function of (element attributes, spelling) — the same
608
+ # conversions repeat per rule application (~450k find/slice
609
+ # allocations per ISO-13849 parse before this memo).
610
+ conversions = session&.rule_name_resolution(doc.root)
611
+ if conversions
612
+ return conversions[rule_name] if conversions.key?(rule_name)
613
+
614
+ converted = convert_rule_name_uncached(doc, rule_name, session)
615
+ conversions[rule_name] = converted
616
+ else
617
+ convert_rule_name_uncached(doc, rule_name, session)
618
+ end
619
+ end
620
+
621
+ def convert_rule_name_uncached(doc, rule_name, session)
603
622
  # URI-vs-prefix detection works on the whole rule name: the
604
623
  # namespace part is a prefix of rule_name, so "://" anywhere in
605
624
  # the name means a URI-form namespace, and a "urn:" namespace
@@ -620,10 +639,13 @@ _effective_register)
620
639
  candidates = session.element_local_attribute_index(doc.root)[local_name]
621
640
  # No candidate carries this local name: nothing in the
622
641
  # element can match, the scan is skipped entirely.
642
+ # getbyte, not a [i] slice — the slice allocated one
643
+ # string per probed candidate (~380k per ISO-13849 parse).
623
644
  matched = candidates&.find do |attr|
624
645
  ns = attr.namespace
625
646
  next false if ns.nil? || ns.length > last_colon_index
626
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
647
+ next false unless rule_name.start_with?(ns) &&
648
+ rule_name.getbyte(ns.length) == COLON_BYTE
627
649
 
628
650
  attr.unprefixed_name == local_name
629
651
  end
@@ -631,7 +653,8 @@ _effective_register)
631
653
  matched = doc.root.attributes.each_value.find do |attr|
632
654
  ns = attr.namespace
633
655
  next false if ns.nil? || ns.length > last_colon_index
634
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
656
+ next false unless rule_name.start_with?(ns) &&
657
+ rule_name.getbyte(ns.length) == COLON_BYTE
635
658
 
636
659
  attr.unprefixed_name == local_name
637
660
  end
@@ -661,13 +684,27 @@ _effective_register)
661
684
  def find_attribute_by_local_name(doc, rule_names,
662
685
  flexible_local: false, session: nil)
663
686
  root_index = session&.element_local_attribute_index(doc.root)
687
+ # Matched VALUE per (element, spelling, flexible flag) — pure
688
+ # while the element is parse-frozen.
689
+ memo = if session
690
+ session.lenient_local_matches(doc.root,
691
+ flexible_local)
692
+ else
693
+ nil
694
+ end
664
695
 
665
696
  rule_names.each do |rule_name|
666
697
  next unless rule_name.include?(":")
667
698
 
668
- resolved = match_attribute_by_local_name(
669
- doc, rule_name, root_index, flexible_local
670
- )
699
+ resolved = if memo&.key?(rule_name)
700
+ memo[rule_name]
701
+ else
702
+ match = match_attribute_by_local_name(
703
+ doc, rule_name, root_index, flexible_local
704
+ )
705
+ memo[rule_name] = match if memo
706
+ match
707
+ end
671
708
  return resolved if resolved
672
709
  end
673
710
  nil
@@ -686,6 +723,11 @@ _effective_register)
686
723
  local_name, rule_uri = parts
687
724
 
688
725
  candidates = root_index&.[](local_name)
726
+ # The index carries every local form of every attribute on the
727
+ # element — a miss means no attribute can match, so the full
728
+ # each_value scan below is dead whenever the index exists.
729
+ return nil if root_index && !candidates
730
+
689
731
  matched_attr = if candidates
690
732
  candidates.find do |attr|
691
733
  local_name_match?(attr, local_name, rule_uri,
@@ -1009,6 +1051,15 @@ _effective_register)
1009
1051
  end
1010
1052
  end
1011
1053
 
1054
+ # lutaml-model#88: attribute-value discriminator — among the
1055
+ # name-matched occurrences, keep only those whose sibling
1056
+ # attributes carry the expected values.
1057
+ if rule.when_attribute?
1058
+ children = children.select do |child|
1059
+ rule.matches_when_attribute?(child)
1060
+ end
1061
+ end
1062
+
1012
1063
  # Performance: Cache rule method check before loop to avoid repeated method calls
1013
1064
  rule_has_custom_method = rule.has_custom_method_for_deserialization?
1014
1065
  if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
@@ -29,8 +29,17 @@ module Lutaml
29
29
  # functions of the element and the name; ISO-13849-shaped
30
30
  # documents repeat the same resolutions per rule application, so
31
31
  # the computed answer is memoized per element for the parse.
32
- def rule_name_resolution
33
- @rule_name_resolution ||= {}
32
+ def rule_name_resolution(element)
33
+ @rule_name_resolution ||= {}.compare_by_identity
34
+ @rule_name_resolution[element] ||= {}
35
+ end
36
+
37
+ # Lenient local-name fallback matches, per (element, flexible_local):
38
+ # spelling -> matched attribute VALUE. Same purity argument as
39
+ # rule_name_resolution — elements are parse-frozen.
40
+ def lenient_local_matches(element, flexible_local)
41
+ @lenient_local_matches ||= {}.compare_by_identity
42
+ (@lenient_local_matches[element] ||= {})[flexible_local] ||= {}
34
43
  end
35
44
 
36
45
  # Local-name -> [attributes] index over one element's attributes,
@@ -42,12 +42,31 @@ register_id, register)
42
42
  rule.attribute_type < Lutaml::Model::Serialize
43
43
  end
44
44
 
45
- if is_nested_model
46
- create_nested_model_element(rule, value, options, register,
47
- union: union)
48
- else
49
- create_simple_value_element(rule, value, options, model_class,
50
- register_id)
45
+ element = if is_nested_model
46
+ create_nested_model_element(rule, value, options,
47
+ register, union: union)
48
+ else
49
+ create_simple_value_element(rule, value, options,
50
+ model_class, register_id)
51
+ end
52
+ apply_when_attribute(element, rule)
53
+ element
54
+ end
55
+
56
+ # lutaml-model#88: re-emit the discriminator attributes on the
57
+ # element created for this rule, unless the value's own mapping
58
+ # already wrote them.
59
+ def apply_when_attribute(element, rule)
60
+ pairs = rule.option(:when_attribute)
61
+ return if pairs.nil? || pairs.empty?
62
+ return if element.nil?
63
+
64
+ pairs.each do |name, expected|
65
+ next if element.attributes.any? { |a| a.name == name.to_s }
66
+
67
+ element.attributes << ::Lutaml::Xml::DataModel::XmlAttribute.new(
68
+ name.to_s, expected.to_s
69
+ )
51
70
  end
52
71
  end
53
72
 
@@ -337,6 +337,7 @@ register_id, register, attr_name, custom_methods_value)
337
337
  value_map: value_map,
338
338
  custom_methods: custom_methods_value,
339
339
  polymorphic: mapping_rule.polymorphic,
340
+ when_attribute: mapping_rule.when_attribute,
340
341
  form: mapping_rule.form,
341
342
  delegate_from: delegate_target,
342
343
  )
@@ -417,6 +418,7 @@ register_id, register, custom_methods_value)
417
418
  value_map: value_map,
418
419
  custom_methods: custom_methods_value,
419
420
  polymorphic: mapping_rule.polymorphic,
421
+ when_attribute: mapping_rule.when_attribute,
420
422
  form: mapping_rule.form,
421
423
  alias_names: alias_names,
422
424
  )
@@ -137,26 +137,36 @@ RSpec.describe Lutaml::KeyValue::Transformation::RuleCompiler do
137
137
  end
138
138
 
139
139
  describe "#valid_mapping?" do
140
- let(:rule) { double("CompiledRule", attribute_name: :name) }
140
+ def compiled_rule(serialize: true)
141
+ Lutaml::Model::CompiledRule.new(
142
+ attribute_name: :name,
143
+ serialized_name: "name",
144
+ serialize: serialize,
145
+ )
146
+ end
141
147
 
142
148
  it "returns true when no only/except options" do
143
- expect(compiler.valid_mapping?(rule, {})).to be true
149
+ expect(compiler.valid_mapping?(compiled_rule, {})).to be true
144
150
  end
145
151
 
146
152
  it "returns true when attribute is in only list" do
147
- expect(compiler.valid_mapping?(rule, { only: [:name] })).to be true
153
+ expect(compiler.valid_mapping?(compiled_rule, { only: [:name] })).to be true
148
154
  end
149
155
 
150
156
  it "returns false when attribute is not in only list" do
151
- expect(compiler.valid_mapping?(rule, { only: [:age] })).to be false
157
+ expect(compiler.valid_mapping?(compiled_rule, { only: [:age] })).to be false
152
158
  end
153
159
 
154
160
  it "returns true when attribute is not in except list" do
155
- expect(compiler.valid_mapping?(rule, { except: [:age] })).to be true
161
+ expect(compiler.valid_mapping?(compiled_rule, { except: [:age] })).to be true
156
162
  end
157
163
 
158
164
  it "returns false when attribute is in except list" do
159
- expect(compiler.valid_mapping?(rule, { except: [:name] })).to be false
165
+ expect(compiler.valid_mapping?(compiled_rule, { except: [:name] })).to be false
166
+ end
167
+
168
+ it "returns false for a hydrate-only rule (serialize: false)" do
169
+ expect(compiler.valid_mapping?(compiled_rule(serialize: false), {})).to be false
160
170
  end
161
171
  end
162
172
 
@@ -283,7 +283,10 @@ RSpec.describe Lutaml::KeyValue::Transformation do
283
283
 
284
284
  expect(transformation).to be_frozen
285
285
 
286
- rule = instance_double(Lutaml::Model::CompiledRule, attribute_name: :name)
286
+ rule = Lutaml::Model::CompiledRule.new(
287
+ attribute_name: :name,
288
+ serialized_name: "name",
289
+ )
287
290
  expect do
288
291
  transformation.send(:valid_mapping?, rule, {})
289
292
  end.not_to raise_error
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#815: standalone `SomeModel.new.to_xml` crashed on 0.8.42 when
6
+ # the model's attributes are merged through register records whose imports
7
+ # clear @merged_attributes_cache re-entrantly from inside ensure_imports! —
8
+ # the memo store was materialized before ensure_imports! and the write after
9
+ # it hit nil[].
10
+ RSpec.describe "attributes memo across import-triggered cache clears" do
11
+ let(:namespace_class) do
12
+ Class.new(Lutaml::Xml::W3c::XmlNamespace) do
13
+ uri "https://example.com/ns815"
14
+ end
15
+ end
16
+
17
+ let(:register) do
18
+ Lutaml::Model::Register.new(:attrs815, fallback: [:default]).tap do |reg|
19
+ reg.bind_namespace(namespace_class)
20
+ end
21
+ end
22
+
23
+ let(:base_model) do
24
+ Class.new(Lutaml::Model::Serializable) do
25
+ attribute :shared, :string
26
+
27
+ xml do
28
+ element "Base815"
29
+ namespace namespace_class
30
+ map_element "shared", to: :shared
31
+ end
32
+ end
33
+ end
34
+
35
+ let(:consumer_model) do
36
+ Class.new(Lutaml::Model::Serializable) do
37
+ attribute :own, :string
38
+
39
+ xml do
40
+ element "Consumer815"
41
+ namespace namespace_class
42
+ map_element "own", to: :own
43
+ end
44
+ end
45
+ end
46
+
47
+ before do
48
+ stub_const("Attrs815::NamespaceClass", namespace_class)
49
+ Lutaml::Model::GlobalContext.reset!
50
+ Lutaml::Model::GlobalRegister.instance.register(register)
51
+ # Populate @register_records through the import the parse path uses.
52
+ base_model.import_model_attributes(consumer_model, register.id)
53
+ end
54
+
55
+ after do
56
+ Lutaml::Model::GlobalContext.reset!
57
+ end
58
+
59
+ it "serializes a register-bound model as its own document root" do
60
+ instance = base_model.new(shared: "x")
61
+ expect(instance.to_xml).to include("<shared>x</shared>")
62
+ end
63
+
64
+ it "merges register attributes after an import-triggered cache clear" do
65
+ instance = consumer_model.new(own: "y")
66
+ expect(instance.to_xml).to include("<own>y</own>")
67
+ end
68
+
69
+ it "keeps answering attributes() from the memo after imports" do
70
+ first = consumer_model.attributes(:attrs815)
71
+ expect(consumer_model.attributes(:attrs815)).to equal(first)
72
+ end
73
+
74
+ it "survives a re-entrant cache clear from inside ensure_imports!" do
75
+ # uniword's register-bound models (#815): choice/restrict resolution
76
+ # runs clear_cache while ensure_imports! is still on the stack, which
77
+ # nils @merged_attributes_cache after the reader materialized it.
78
+ reentrant = Class.new(base_model) do
79
+ def self.finalized? = true
80
+
81
+ def self.ensure_format_mapping_imports!(_register = nil)
82
+ clear_cache
83
+ super
84
+ end
85
+ end
86
+
87
+ expect { reentrant.attributes(:attrs815) }.not_to raise_error
88
+ expect(reentrant.attributes(:attrs815)[:shared].name).to eq(:shared)
89
+ end
90
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../lib/lutaml/model"
5
+
6
+ module HydrateOnlyMapping
7
+ class Message < Lutaml::Model::Serializable
8
+ attribute :body, :string
9
+ attribute :source_offset, :integer
10
+
11
+ key_value do
12
+ map "body", to: :body
13
+ map "source_offset", to: :source_offset, serialize: false
14
+ end
15
+ end
16
+ end
17
+
18
+ RSpec.describe "hydrate-only mappings (serialize: false)" do
19
+ let(:input) { { "body" => "hello", "source_offset" => 42 } }
20
+
21
+ it "hydrates the attribute from input" do
22
+ message = HydrateOnlyMapping::Message.from_hash(input)
23
+
24
+ expect(message.body).to eq("hello")
25
+ expect(message.source_offset).to eq(42)
26
+ end
27
+
28
+ it "omits the attribute from serialized output" do
29
+ message = HydrateOnlyMapping::Message.from_hash(input)
30
+
31
+ expect(message.to_hash).to eq("body" => "hello")
32
+ end
33
+
34
+ it "round-trips through JSON without the hidden key" do
35
+ message = HydrateOnlyMapping::Message.from_json(input.to_json)
36
+
37
+ expect(JSON.parse(message.to_json)).to eq("body" => "hello")
38
+ end
39
+
40
+ it "keeps the mapping through deep-copied mappings" do
41
+ mapping = HydrateOnlyMapping::Message.mappings_for(:hash)
42
+ rule = mapping.mappings.find { |r| r.to == :source_offset }
43
+
44
+ expect(mapping.deep_dup.mappings.find { |r| r.to == :source_offset }
45
+ .serialize?).to eq(rule.serialize?)
46
+ end
47
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#88: attribute-value dispatch on a shared wire name.
6
+ # `map_element "component", to: :guidance, when_attribute: { "type" =>
7
+ # "guidance" }` selects only the occurrences whose sibling attribute carries
8
+ # the expected value, and re-emits the discriminator on serialization.
9
+ class WhenAttrComponent < 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 WhenAttrRequirement < Lutaml::Model::Serializable
19
+ attribute :guidance, WhenAttrComponent, collection: true
20
+ attribute :purpose, WhenAttrComponent, collection: true
21
+ attribute :test_method, WhenAttrComponent, collection: true
22
+
23
+ xml do
24
+ element "requirement"
25
+ map_element "component", to: :guidance,
26
+ when_attribute: { "type" => "guidance" }
27
+ map_element "component", to: :purpose,
28
+ when_attribute: { "type" => "purpose" }
29
+ map_element "component", to: :test_method,
30
+ when_attribute: { type: "test-method" }
31
+ end
32
+ end
33
+
34
+ RSpec.describe "when_attribute discriminator mappings" do
35
+ before do
36
+ stub_const("WhenAttr::Component", WhenAttrComponent)
37
+ stub_const("WhenAttr::Requirement", WhenAttrRequirement)
38
+ end
39
+
40
+ let(:xml) do
41
+ <<~XML
42
+ <requirement>
43
+ <component type="guidance"><text>g1</text></component>
44
+ <component type="purpose"><text>p1</text></component>
45
+ <component type="guidance"><text>g2</text></component>
46
+ <component type="test-method"><text>t1</text></component>
47
+ </requirement>
48
+ XML
49
+ end
50
+
51
+ it "dispatches occurrences by the discriminator value" do
52
+ req = WhenAttr::Requirement.from_xml(xml)
53
+
54
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
55
+ expect(req.purpose.map(&:text)).to eq(["p1"])
56
+ expect(req.test_method.map(&:text)).to eq(["t1"])
57
+ end
58
+
59
+ it "keeps document order within each target attribute" do
60
+ req = WhenAttr::Requirement.from_xml(xml)
61
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
62
+ end
63
+
64
+ it "drops occurrences whose discriminator is not covered" do
65
+ doc = '<requirement><component type="unknown"><text>x</text></component></requirement>'
66
+ req = WhenAttr::Requirement.from_xml(doc)
67
+
68
+ expect(req.guidance).to be_empty
69
+ expect(req.purpose).to be_empty
70
+ end
71
+
72
+ it "re-emits the discriminator attribute on serialization" do
73
+ round = WhenAttr::Requirement.from_xml(WhenAttr::Requirement.from_xml(xml).to_xml)
74
+
75
+ expect(round.guidance.map(&:text)).to eq(%w[g1 g2])
76
+ expect(round.purpose.map(&:text)).to eq(["p1"])
77
+ expect(round.test_method.map(&:text)).to eq(["t1"])
78
+
79
+ serialized = WhenAttr::Requirement.from_xml(xml).to_xml
80
+ expect(serialized).to include('type="guidance"')
81
+ expect(serialized).to include('type="purpose"')
82
+ expect(serialized).to include('type="test-method"')
83
+ end
84
+
85
+ it "does not emit a discriminator for ordinary rules" do
86
+ plain = Class.new(Lutaml::Model::Serializable) do
87
+ attribute :text, :string
88
+
89
+ xml do
90
+ element "plain"
91
+ map_element "text", to: :text
92
+ end
93
+ end
94
+
95
+ expect(plain.from_xml("<plain><text>t</text></plain>").to_xml)
96
+ .not_to include("type=")
97
+ end
98
+
99
+ it "survives deep_dup of mappings" do
100
+ mapping = WhenAttr::Requirement.mappings_for(:xml)
101
+ duped = mapping.deep_dup.mappings.find do |r|
102
+ r.to == :guidance
103
+ end
104
+
105
+ expect(duped.when_attribute).to eq("type" => "guidance")
106
+ end
107
+
108
+ it "rejects non-string/symbol discriminator shapes" do
109
+ expect do
110
+ Class.new(Lutaml::Model::Serializable) do
111
+ attribute :x, :string
112
+
113
+ xml do
114
+ element "x"
115
+ map_element "x", to: :x, when_attribute: { "type" => 42 }
116
+ end
117
+ end
118
+ end.to raise_error(Lutaml::Model::IncorrectMappingArgumentsError, /when_attribute/)
119
+ end
120
+
121
+ it "is rejected on attribute mappings" do
122
+ expect do
123
+ Class.new(Lutaml::Model::Serializable) do
124
+ attribute :x, :string
125
+
126
+ xml do
127
+ element "x"
128
+ map_attribute "x", to: :x, when_attribute: { "type" => "guidance" }
129
+ end
130
+ end
131
+ end.to raise_error(ArgumentError)
132
+ end
133
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.42
4
+ version: 0.8.43
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-09-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1695,6 +1695,7 @@ files:
1695
1695
  - spec/lutaml/model/attribute_form_default_spec.rb
1696
1696
  - spec/lutaml/model/attribute_restriction_spec.rb
1697
1697
  - spec/lutaml/model/attribute_spec.rb
1698
+ - spec/lutaml/model/attributes_memo_clear_race_spec.rb
1698
1699
  - spec/lutaml/model/boot_manifest_spec.rb
1699
1700
  - spec/lutaml/model/cached_type_resolver_spec.rb
1700
1701
  - spec/lutaml/model/cardinality_strict_spec.rb
@@ -1737,6 +1738,7 @@ files:
1737
1738
  - spec/lutaml/model/global_register_spec.rb
1738
1739
  - spec/lutaml/model/group_spec.rb
1739
1740
  - spec/lutaml/model/hash/adapter_spec.rb
1741
+ - spec/lutaml/model/hydrate_only_mapping_spec.rb
1740
1742
  - spec/lutaml/model/import_registry_spec.rb
1741
1743
  - spec/lutaml/model/included_spec.rb
1742
1744
  - spec/lutaml/model/inheritance_spec.rb
@@ -2004,6 +2006,7 @@ files:
2004
2006
  - spec/lutaml/xml/validate_xml_with_constructs_spec.rb
2005
2007
  - spec/lutaml/xml/validate_xml_with_spec.rb
2006
2008
  - spec/lutaml/xml/w3c_types_spec.rb
2009
+ - spec/lutaml/xml/when_attribute_spec.rb
2007
2010
  - spec/lutaml/xml/xml_adapter_spec.rb
2008
2011
  - spec/lutaml/xml/xml_declaration_spec.rb
2009
2012
  - spec/lutaml/xml/xml_element_guard_spec.rb