lutaml-model 0.8.90 → 0.8.92

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: '008334bfed6208a5b73319ca45ef3203aa8de4807daadc7087278b76143a69a3'
4
- data.tar.gz: 7f1381b7380337212ace593d64a11fc2e36c990c44304432ba1e7ea6f41887bf
3
+ metadata.gz: 19d1cc9a53554486e1dda21b67751062851d1990b9878aac1dcd6ccd2542d28e
4
+ data.tar.gz: 459cecd7cbdd127bb65a52cd6058d027e5139f2926e46d408bea5a28b13f2e7f
5
5
  SHA512:
6
- metadata.gz: 626ce7c079e4df067729318927eb539b94397e84fe06ad6586eb9c3de63798eabcb24c86204ad10a26685173403f02a5bab80a75217d81cf4d02966a6dbc66fa
7
- data.tar.gz: e27d1de126fa01354facfd4c42136bf6cce400a243ffa8a20f91a421ae8d2743d2df4f588a12d9b18ae96a067dd322b126a6362c91a12c601bc2dbe56e3a6ed5
6
+ metadata.gz: 92637d4ab1efbd63d746983b4e359f2d49c00469e94bb249a050f36f1b80f15b18ae705245fdd8ab563d854e814fea54ce99993f64dbc17beb9c87555f7b3a95
7
+ data.tar.gz: 54412f06f6f1bed27f74a85495b414cfc6da3274ab03214220c27edd344aff8cd58f70874fa317fb61b9e8523a04725d69152dfde490b3aa61a2d75540a161ed
@@ -20,7 +20,7 @@ if ENV["PLAN_COMPILE_STATS"]
20
20
  at_exit do
21
21
  require "pp"
22
22
  puts "\nPlanCompiler opt-out histogram:"
23
- pp(Lutaml::Xml::Leptris::PlanCompiler.stats)
23
+ pp(Lutaml::Xml::Leptris::PlanCompiler.plan_stats)
24
24
  end
25
25
  end
26
26
  require "benchmark"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.90"
5
+ VERSION = "0.8.92"
6
6
  end
7
7
  end
@@ -26,9 +26,9 @@ module Lutaml
26
26
  # metadata) completes.
27
27
  module PlanCompiler
28
28
  # TODO.perf/15: env-gated histogram of why models opt out of the
29
- # plan path. Set PLAN_COMPILE_STATS=1 and read PlanCompiler.stats
30
- # after a parse. Zero cost when disabled.
31
- def self.stats
29
+ # plan path. Set PLAN_COMPILE_STATS=1 and read
30
+ # PlanCompiler.plan_stats after a parse. Zero cost when disabled.
31
+ def self.plan_stats
32
32
  @plan_stats ||= Hash.new(0)
33
33
  end
34
34
 
@@ -1197,6 +1197,31 @@ module Lutaml
1197
1197
  # transform applies the collection-attribute filter.
1198
1198
  #
1199
1199
  # @return [Hash{Symbol, String => Array<MappingRule>}]
1200
+ # The when_attribute siblings-vs-plain-names partition of the
1201
+ # element rules. A ParseSession used to rebuild this per parsed
1202
+ # node (sessions are per-element), paying an O(rules) pass on
1203
+ # every node of large documents; it is a pure function of the
1204
+ # rules, so it memoizes here — same lifetime as
1205
+ # plain_element_rules_by_attr.
1206
+ def when_attribute_partition
1207
+ @when_attribute_partition ||= begin
1208
+ siblings = {}
1209
+ plain_names = {}
1210
+ mappings.each do |rule|
1211
+ next if rule.attribute? || rule.content_mapping? ||
1212
+ rule.raw_mapping? || rule.cdata
1213
+
1214
+ pairs = rule.when_attribute
1215
+ if pairs && !pairs.empty?
1216
+ (siblings[rule.name.to_s] ||= []) << rule
1217
+ else
1218
+ plain_names[rule.name.to_s] = true
1219
+ end
1220
+ end
1221
+ [siblings, plain_names]
1222
+ end
1223
+ end
1224
+
1200
1225
  def plain_element_rules_by_attr(register_id = nil)
1201
1226
  reg_key = register_id || :default
1202
1227
  @plain_element_rules_by_attr ||= {}
@@ -344,6 +344,14 @@ module Lutaml
344
344
  deserialize_attributes = attributes
345
345
  mappings.each do |rule|
346
346
  record = records&.[](rule)
347
+
348
+ # #903: replay-or-drop unmatched named element rules before
349
+ # any per-rule machinery (see replay_unmatched_rule).
350
+ if replay_unmatched_rule(child_names_set, record, rule,
351
+ instance, defaults_used, xml_mapping)
352
+ next
353
+ end
354
+
347
355
  rule_to = rule.to
348
356
 
349
357
  if record
@@ -847,7 +855,9 @@ _effective_register)
847
855
  Lutaml::Xml::HtmlEntities.decode(value)
848
856
  end
849
857
 
850
- Record = Struct.new(:attr, :derived, :group_skip, :valid)
858
+ Record = Struct.new(:attr, :derived, :group_skip, :valid,
859
+ :unmatched_mode, :replay_writer,
860
+ :unmatched_names)
851
861
 
852
862
  def compiled_rule_records(model_class, register, grouped_plain_rules)
853
863
  return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
@@ -866,12 +876,76 @@ _effective_register)
866
876
  mapping&.mappings(register)&.each do |rule| # rubocop:disable Style/SafeNavigationChain,Style/SafeNavigation
867
877
  attr = attribute_for_rule_static(model_class, rule, register)
868
878
  group = grouped_plain_rules[rule.to]
879
+ # #903: for a plain rule, what an unmatched visit writes is
880
+ # known statically. The fast-skip branch hands the attribute's
881
+ # uninitialized sentinel to the pipeline; with no value-map
882
+ # overrides and no transformers every step is identity except
883
+ # apply_value_map, which maps the :omitted key: the default
884
+ # map (:nil) yields nil and the visit writes nil through the
885
+ # setter; an explicit omitted: :omitted map treats it as
886
+ # treat_omitted? == false and the omission gate ends the
887
+ # visit untouched. Both endings replay without the walk:
888
+ # :write_nil (setter write + flag churn) or :drop (nothing).
889
+ # Everything else -- defaults, aliases, custom from:,
890
+ # delegates, when_attribute dispatch, transformers, value
891
+ # maps beyond those two -- keeps its visit.
892
+ unmatched_mode = nil
893
+ replay_writer = nil
894
+ if attr && !attr.derived? &&
895
+ !rule.render_default && !rule.multiple_mappings? &&
896
+ !rule.has_custom_method_for_deserialization? &&
897
+ !rule.delegate &&
898
+ !(rule.when_attribute && !rule.when_attribute.empty?)
899
+ rule_transform = rule.transform
900
+ attr_transform = attr.transform
901
+ no_transforms =
902
+ (rule_transform.nil? ||
903
+ (rule_transform.respond_to?(:empty?) &&
904
+ rule_transform.empty?)) &&
905
+ (attr_transform.nil? ||
906
+ (attr_transform.respond_to?(:empty?) &&
907
+ attr_transform.empty?))
908
+ rule_default = attr.default(register)
909
+ if no_transforms &&
910
+ (rule_default.nil? ||
911
+ rule_default.is_a?(::Lutaml::Model::UninitializedClass)) &&
912
+ rule.class.transform_dispatch(rule, attr) == :assign
913
+ if group && group.size > 1 && attr.collection? &&
914
+ !group.first.equal?(rule)
915
+ # Non-first spelling of a #765 group: the visit is a
916
+ # guarded next (the first member does the merged match).
917
+ unmatched_mode = :drop
918
+ else
919
+ omitted_val = rule.value_map(:from)[:omitted]
920
+ if omitted_val == :nil
921
+ unmatched_mode = :write_nil
922
+ elsif omitted_val == :omitted
923
+ unmatched_mode = :drop
924
+ end
925
+ end
926
+ end
927
+ end
928
+ # A first group member does the merged match for every
929
+ # spelling, so its name-miss test spans the whole group.
930
+ unmatched_names = if group && group.size > 1
931
+ group.map { |r| r.name.to_s }
932
+ else
933
+ [rule.name.to_s]
934
+ end
935
+ if unmatched_mode == :write_nil
936
+ replay_writer =
937
+ rule.class.parsed_assign_writer(model_class, rule.to)
938
+ unmatched_mode = nil unless replay_writer
939
+ end
869
940
  records[rule] = Record.new(
870
941
  attr,
871
942
  attr&.derived? || false,
872
943
  !!(group && group.size > 1 && attr&.collection? &&
873
944
  !group.first.equal?(rule)),
874
945
  !!(attr || rule.custom_methods[:from]),
946
+ unmatched_mode,
947
+ replay_writer,
948
+ unmatched_names,
875
949
  )
876
950
  end
877
951
  entry = Struct.new(:version, :records).new(version, records)
@@ -1551,6 +1625,34 @@ effective_register = lutaml_register)
1551
1625
  # @param child_names_set [Set] Set of child namespaced_name and unprefixed_name
1552
1626
  # @param default_namespace [String, nil] the default namespace
1553
1627
  # @return [Boolean] true if a matching child likely exists
1628
+ # #903: for an unmatched named element rule whose record classed
1629
+ # it, replay what the visit would write without the walk: :drop
1630
+ # (the visit ended at the omission gate or was a guarded next)
1631
+ # writes nothing; :write_nil replays the nil the default value
1632
+ # map produces, plus the flag churn. Returns true when handled.
1633
+ def replay_unmatched_rule(child_names_set, record, rule, instance,
1634
+ defaults_used, xml_mapping)
1635
+ return false unless child_names_set && record
1636
+
1637
+ mode = record.unmatched_mode
1638
+ return false unless mode
1639
+ return false if rule.attribute? || rule.content_mapping? ||
1640
+ rule.raw_mapping? || !rule.name ||
1641
+ xml_mapping.any_element_rule.equal?(rule)
1642
+
1643
+ matched = record.unmatched_names.any? do |n|
1644
+ child_names_set.include?(n)
1645
+ end
1646
+ return false if matched
1647
+
1648
+ return true if mode == :drop
1649
+
1650
+ defaults_used << rule.to
1651
+ instance.public_send(record.replay_writer, nil)
1652
+ instance.value_set_for(rule.to)
1653
+ true
1654
+ end
1655
+
1554
1656
  def child_matches_rule?(rule, child_names_set, default_namespace)
1555
1657
  rule_names = rule.namespaced_names(default_namespace)
1556
1658
  # Array#intersect? requires an Array argument; child_names_set may
@@ -58,7 +58,10 @@ module Lutaml
58
58
  # @param value [String, nil] the namespace URI
59
59
  # @return [String, nil] the namespace URI
60
60
  def uri(value = nil)
61
- @uri_value = value if value
61
+ if value
62
+ @uri_value = value
63
+ @to_key = nil
64
+ end
62
65
  @uri_value
63
66
  end
64
67
 
@@ -130,6 +133,7 @@ module Lutaml
130
133
  def prefix_default(value = nil)
131
134
  if value
132
135
  @prefix_default_value = value.to_s
136
+ @to_key = nil
133
137
  end
134
138
  @prefix_default_value
135
139
  end
@@ -286,13 +290,19 @@ module Lutaml
286
290
  #
287
291
  # @api private
288
292
  def to_key
289
- prefix = prefix_default
290
- namespace_uri = uri
291
-
292
- if prefix && !prefix.empty?
293
- "#{prefix}:#{namespace_uri}"
294
- else
295
- ":#{namespace_uri}"
293
+ # Called per element on the serialize path (90k births per
294
+ # 200-paragraph to_xml); memoized since uri/prefix_default
295
+ # are set at class-definition time. The setters above
296
+ # invalidate on reconfiguration.
297
+ @to_key ||= begin
298
+ prefix = prefix_default
299
+ namespace_uri = uri
300
+
301
+ if prefix && !prefix.empty?
302
+ "#{prefix}:#{namespace_uri}"
303
+ else
304
+ ":#{namespace_uri}"
305
+ end
296
306
  end
297
307
  end
298
308
 
@@ -8,20 +8,45 @@ module Lutaml
8
8
  # This class maintains MECE responsibility: it only stores and organizes
9
9
  # namespace needs data. It does NOT make decisions or build XML.
10
10
  class NamespaceNeeds
11
- attr_reader :namespaces, :children, :type_namespaces,
12
- :type_namespace_classes, :type_attribute_namespaces,
13
- :type_element_namespaces, :type_refs,
14
- :namespace_scope_configs
15
-
16
- def initialize
17
- @namespaces = {} # Hash<String, NamespaceUsage>
18
- @children = {} # Hash<Symbol, NamespaceNeeds>
19
- @type_namespaces = {} # Hash<Symbol, Class>
20
- @type_namespace_classes = Set.new # Set<Class>
21
- @type_attribute_namespaces = Set.new # Set<Class>
22
- @type_element_namespaces = Set.new # Set<Class>
23
- @type_refs = [] # Array<TypeNamespace::Reference>
24
- @namespace_scope_configs = [] # Array<NamespaceScopeConfig>
11
+ # Lazy readers: leaf nodes never touch most containers; the
12
+ # eager 4-Sets + 3-Hashes + 2-Arrays was the serialize path's
13
+ # top allocation site (150k births per 200-paragraph to_xml).
14
+ # Readers get the frozen empty truth; writers build on first
15
+ # touch through the ensure_* helpers below.
16
+ EMPTY_HASH = {}.freeze
17
+ EMPTY_SET = ::Set.new.freeze
18
+ EMPTY_ARRAY = [].freeze
19
+
20
+ def namespaces
21
+ @namespaces || EMPTY_HASH
22
+ end
23
+
24
+ def children
25
+ @children || EMPTY_HASH
26
+ end
27
+
28
+ def type_namespaces
29
+ @type_namespaces || EMPTY_HASH
30
+ end
31
+
32
+ def type_namespace_classes
33
+ @type_namespace_classes || EMPTY_SET
34
+ end
35
+
36
+ def type_attribute_namespaces
37
+ @type_attribute_namespaces || EMPTY_SET
38
+ end
39
+
40
+ def type_element_namespaces
41
+ @type_element_namespaces || EMPTY_SET
42
+ end
43
+
44
+ def type_refs
45
+ @type_refs || EMPTY_ARRAY
46
+ end
47
+
48
+ def namespace_scope_configs
49
+ @namespace_scope_configs || EMPTY_ARRAY
25
50
  end
26
51
 
27
52
  # Add namespace usage for a specific namespace
@@ -32,29 +57,29 @@ module Lutaml
32
57
  raise ArgumentError, "Expected NamespaceUsage, got #{usage.class}"
33
58
  end
34
59
 
35
- @namespaces[key] = usage
60
+ (@namespaces ||= {})[key] = usage
36
61
  end
37
62
 
38
63
  # Track a type namespace for a specific attribute
39
64
  # @param attr_name [Symbol] Attribute name
40
65
  # @param ns_class [Class] XmlNamespace class
41
66
  def add_type_namespace(attr_name, ns_class)
42
- @type_namespaces[attr_name] = ns_class
43
- @type_namespace_classes << ns_class
67
+ (@type_namespaces ||= {})[attr_name] = ns_class
68
+ (@type_namespace_classes ||= ::Set.new) << ns_class
44
69
  end
45
70
 
46
71
  # Add type attribute namespace to tracking set
47
72
  # @param ns_class [Class] XmlNamespace class
48
73
  def add_type_attribute_namespace(ns_class)
49
- @type_attribute_namespaces << ns_class
50
- @type_namespace_classes << ns_class
74
+ (@type_attribute_namespaces ||= ::Set.new) << ns_class
75
+ (@type_namespace_classes ||= ::Set.new) << ns_class
51
76
  end
52
77
 
53
78
  # Add type element namespace to tracking set
54
79
  # @param ns_class [Class] XmlNamespace class
55
80
  def add_type_element_namespace(ns_class)
56
- @type_element_namespaces << ns_class
57
- @type_namespace_classes << ns_class
81
+ (@type_element_namespaces ||= ::Set.new) << ns_class
82
+ (@type_namespace_classes ||= ::Set.new) << ns_class
58
83
  end
59
84
 
60
85
  # Add a type reference for lazy resolution
@@ -65,13 +90,13 @@ module Lutaml
65
90
  "Expected TypeNamespace::Reference, got #{reference.class}"
66
91
  end
67
92
 
68
- @type_refs << reference
93
+ (@type_refs ||= []) << reference
69
94
  end
70
95
 
71
96
  # Clear type references after resolution
72
97
  # Used by TypeNamespaceResolver to prevent reprocessing
73
98
  def clear_type_refs
74
- @type_refs.clear
99
+ @type_refs&.clear
75
100
  end
76
101
 
77
102
  # Add namespace scope configuration
@@ -82,7 +107,7 @@ module Lutaml
82
107
  "Expected NamespaceScopeConfig, got #{config.class}"
83
108
  end
84
109
 
85
- @namespace_scope_configs << config
110
+ (@namespace_scope_configs ||= []) << config
86
111
  end
87
112
 
88
113
  # Add child needs
@@ -94,7 +119,7 @@ module Lutaml
94
119
  "Expected NamespaceNeeds, got #{child_needs.class}"
95
120
  end
96
121
 
97
- @children[name] = child_needs
122
+ (@children ||= {})[name] = child_needs
98
123
  end
99
124
 
100
125
  # Merge another NamespaceNeeds into this one
@@ -105,9 +130,9 @@ module Lutaml
105
130
  raise ArgumentError, "Expected NamespaceNeeds, got #{other.class}"
106
131
  end
107
132
 
108
- # Merge namespaces
133
+ # Merge namespaces (build containers on first touch)
109
134
  other.namespaces.each do |key, usage|
110
- if @namespaces.key?(key)
135
+ if (@namespaces ||= {}).key?(key)
111
136
  @namespaces[key].merge(usage)
112
137
  else
113
138
  @namespaces[key] = usage
@@ -116,7 +141,7 @@ module Lutaml
116
141
 
117
142
  # Merge children
118
143
  other.children.each do |name, child_needs|
119
- if @children.key?(name)
144
+ if (@children ||= {}).key?(name)
120
145
  @children[name].merge(child_needs)
121
146
  else
122
147
  @children[name] = child_needs
@@ -124,17 +149,20 @@ module Lutaml
124
149
  end
125
150
 
126
151
  # Merge type namespaces
127
- @type_namespaces.merge!(other.type_namespaces)
128
- @type_namespace_classes.merge(other.type_namespace_classes)
129
- @type_attribute_namespaces.merge(other.type_attribute_namespaces)
130
- @type_element_namespaces.merge(other.type_element_namespaces)
152
+ (@type_namespaces ||= {}).merge!(other.type_namespaces)
153
+ (@type_namespace_classes ||= ::Set.new)
154
+ .merge(other.type_namespace_classes)
155
+ (@type_attribute_namespaces ||= ::Set.new)
156
+ .merge(other.type_attribute_namespaces)
157
+ (@type_element_namespaces ||= ::Set.new)
158
+ .merge(other.type_element_namespaces)
131
159
 
132
160
  # Merge type refs
133
- @type_refs.concat(other.type_refs)
161
+ (@type_refs ||= []).concat(other.type_refs)
134
162
 
135
163
  # Merge namespace scope configs (avoiding duplicates)
136
164
  other.namespace_scope_configs.each do |config|
137
- unless @namespace_scope_configs.any? do |c|
165
+ unless (@namespace_scope_configs ||= []).any? do |c|
138
166
  c.namespace_class == config.namespace_class
139
167
  end
140
168
  @namespace_scope_configs << config
@@ -147,32 +175,32 @@ module Lutaml
147
175
  # Check if needs are empty
148
176
  # @return [Boolean]
149
177
  def empty?
150
- @namespaces.empty? &&
151
- @children.empty? &&
152
- @type_refs.empty? &&
153
- @type_namespaces.empty? &&
154
- @namespace_scope_configs.empty?
178
+ namespaces.empty? &&
179
+ children.empty? &&
180
+ type_refs.empty? &&
181
+ type_namespaces.empty? &&
182
+ namespace_scope_configs.empty?
155
183
  end
156
184
 
157
185
  # Get namespace usage by key
158
186
  # @param key [String] Namespace key
159
187
  # @return [NamespaceUsage, nil]
160
188
  def namespace(key)
161
- @namespaces[key]
189
+ namespaces[key]
162
190
  end
163
191
 
164
192
  # Get child needs by name
165
193
  # @param name [Symbol] Child attribute name
166
194
  # @return [NamespaceNeeds, nil]
167
195
  def child(name)
168
- @children[name]
196
+ children[name]
169
197
  end
170
198
 
171
199
  # Check if a namespace is in scope configuration
172
200
  # @param ns_class [Class] XmlNamespace class
173
201
  # @return [NamespaceScopeConfig, nil]
174
202
  def scope_config_for(ns_class)
175
- @namespace_scope_configs.find do |config|
203
+ namespace_scope_configs.find do |config|
176
204
  config.namespace_class == ns_class
177
205
  end
178
206
  end
@@ -180,28 +208,28 @@ module Lutaml
180
208
  # Get all namespace classes (from usage and type namespaces)
181
209
  # @return [Set<Class>]
182
210
  def all_namespace_classes
183
- namespace_classes = Set.new(@namespaces.values.map(&:namespace_class))
184
- namespace_classes.merge(@type_namespace_classes)
211
+ classes = Set.new(namespaces.values.map(&:namespace_class))
212
+ classes.merge(type_namespace_classes)
185
213
  end
186
214
 
187
215
  # Validate internal consistency
188
216
  # @raise [RuntimeError] if inconsistent state detected
189
217
  def validate!
190
218
  # Type attribute and element namespaces should be mutually exclusive
191
- overlap = @type_attribute_namespaces & @type_element_namespaces
219
+ overlap = type_attribute_namespaces & type_element_namespaces
192
220
  unless overlap.empty?
193
221
  raise "Type namespaces appear in both attribute and element contexts: #{overlap.to_a}"
194
222
  end
195
223
 
196
224
  # All type namespace classes should be in type_namespace_classes
197
- @type_attribute_namespaces.each do |ns_class|
198
- unless @type_namespace_classes.include?(ns_class)
225
+ type_attribute_namespaces.each do |ns_class|
226
+ unless type_namespace_classes.include?(ns_class)
199
227
  raise "Type attribute namespace #{ns_class} not in type_namespace_classes"
200
228
  end
201
229
  end
202
230
 
203
- @type_element_namespaces.each do |ns_class|
204
- unless @type_namespace_classes.include?(ns_class)
231
+ type_element_namespaces.each do |ns_class|
232
+ unless type_namespace_classes.include?(ns_class)
205
233
  raise "Type element namespace #{ns_class} not in type_namespace_classes"
206
234
  end
207
235
  end
@@ -84,25 +84,9 @@ module Lutaml
84
84
  # Empty unless the mapping uses when_attribute at all; non-Serialize
85
85
  # custom models have no mappings to partition.
86
86
  def when_attribute_partition
87
- @when_attribute_partition ||=
88
- if instance_is_serialize && (mapping = xml_mapping)
89
- siblings = {}
90
- plain_names = {}
91
- mapping.mappings.each do |rule|
92
- next if rule.attribute? || rule.content_mapping? ||
93
- rule.raw_mapping? || rule.cdata
94
-
95
- pairs = rule.when_attribute
96
- if pairs && !pairs.empty?
97
- (siblings[rule.name.to_s] ||= []) << rule
98
- else
99
- plain_names[rule.name.to_s] = true
100
- end
101
- end
102
- [siblings, plain_names]
103
- else
104
- [{}, {}]
105
- end
87
+ return [{}, {}] unless instance_is_serialize && (mapping = xml_mapping)
88
+
89
+ mapping.when_attribute_partition
106
90
  end
107
91
 
108
92
  def when_attribute_siblings_by_name
@@ -101,20 +101,26 @@ model_class, register_id)
101
101
  return
102
102
  end
103
103
 
104
- # Extract parent's namespace info for element_form_default inheritance
105
- parent_ns_class = parent.namespace_class
106
- # Only pass element_form_default VALUE if it was explicitly set
107
- # When not set (defaults to :unqualified), pass nil to avoid incorrect blank namespace treatment
108
- parent_element_form_default = if parent_ns_class&.element_form_default_set?
109
- parent_ns_class.element_form_default
110
- end
111
-
112
- # Merge parent context into options
113
- child_options = options.merge(
114
- parent_namespace_class: parent_ns_class,
115
- parent_element_form_default: parent_element_form_default,
116
- parent_element: parent,
117
- )
104
+ # Fast path: the ordered walk pre-sets the parent context keys
105
+ # for every value it emits (one hash per walk, not per value).
106
+ # The three keys are always written together, so a parent
107
+ # identity match implies the namespace context matches too.
108
+ if options[:parent_element] == parent
109
+ child_options = options
110
+ else
111
+ parent_ns_class = parent.namespace_class
112
+ # Only pass element_form_default VALUE if it was explicitly set
113
+ # When not set (defaults to :unqualified), pass nil to avoid incorrect blank namespace treatment
114
+ parent_element_form_default = if parent_ns_class&.element_form_default_set?
115
+ parent_ns_class.element_form_default
116
+ end
117
+
118
+ child_options = options.dup
119
+ child_options[:parent_namespace_class] = parent_ns_class
120
+ child_options[:parent_element_form_default] =
121
+ parent_element_form_default
122
+ child_options[:parent_element] = parent
123
+ end
118
124
 
119
125
  element = yield(rule, value, child_options)
120
126
  parent.add_child(element) if element
@@ -265,6 +265,20 @@ module Lutaml
265
265
  # Loop-invariant for the whole ordered walk (see
266
266
  # apply_standard_rules): one merge per model, not per rule.
267
267
  rule_options = options.merge(current_model: model_instance)
268
+ # `root` is the parent of every element value this walk emits,
269
+ # so the parent context keys are walk-invariant too: set them
270
+ # once here and apply_element_rule_single's fast path reuses
271
+ # this hash for every value instead of merging per value.
272
+ # rule_options itself keeps the caller's parent context for
273
+ # the attribute/content/raw rules dispatched via apply_rule.
274
+ element_value_options = rule_options.dup
275
+ root_ns_class = root.namespace_class
276
+ element_value_options[:parent_namespace_class] = root_ns_class
277
+ element_value_options[:parent_element_form_default] =
278
+ if root_ns_class&.element_form_default_set?
279
+ root_ns_class.element_form_default
280
+ end
281
+ element_value_options[:parent_element] = root
268
282
  apply_rules_in_order(
269
283
  root, model_instance, options,
270
284
  compiled_rules, model_class, register_id
@@ -281,7 +295,7 @@ module Lutaml
281
295
  parent: root,
282
296
  rule: rule,
283
297
  value: value,
284
- options: rule_options,
298
+ options: element_value_options,
285
299
  ) do |r, v, child_opts|
286
300
  element = create_element_for_value(r, v, child_opts, model_class,
287
301
  register_id, register)
data/lutaml-model.gemspec CHANGED
@@ -43,7 +43,7 @@ Gem::Specification.new do |spec|
43
43
  # input-buffer cap (lutaml-model#871).
44
44
  spec.add_dependency "moxml", "~> 0.5.84"
45
45
  spec.add_dependency "ostruct"
46
- spec.add_dependency "rubyzip", "~> 3.4"
46
+ spec.add_dependency "rubyzip", "~> 3.7"
47
47
  spec.add_dependency "thor"
48
48
 
49
49
  spec.metadata["rubygems_mfa_required"] = "true"
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.90
4
+ version: 0.8.92
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-10-02 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -120,14 +120,14 @@ dependencies:
120
120
  requirements:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
- version: '3.4'
123
+ version: '3.7'
124
124
  type: :runtime
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: '3.4'
130
+ version: '3.7'
131
131
  - !ruby/object:Gem::Dependency
132
132
  name: thor
133
133
  requirement: !ruby/object:Gem::Requirement