lutaml-model 0.8.18 → 0.9.1

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.

Potentially problematic release.


This version of lutaml-model might be problematic. Click here for more details.

Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +1 -1
  3. data/.github/workflows/rake.yml +1 -0
  4. data/.rubocop_todo.yml +133 -72
  5. data/bench/bench_common.rb +6 -3
  6. data/bench/bench_niso.rb +1 -2
  7. data/docs/_guides/builder-dsl.adoc +127 -0
  8. data/lib/compat/opal/generate_boot.rb +3 -1
  9. data/lib/lutaml/key_value/transform.rb +4 -1
  10. data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
  11. data/lib/lutaml/model/attribute.rb +23 -2
  12. data/lib/lutaml/model/global_context.rb +4 -2
  13. data/lib/lutaml/model/schema/class_loader.rb +2 -1
  14. data/lib/lutaml/model/schema/definitions/namespace.rb +2 -1
  15. data/lib/lutaml/model/schema/file_writer.rb +5 -2
  16. data/lib/lutaml/model/schema/renderers/base.rb +2 -1
  17. data/lib/lutaml/model/schema/renderers/mappings.rb +9 -3
  18. data/lib/lutaml/model/schema/renderers/member_decls.rb +9 -3
  19. data/lib/lutaml/model/schema/renderers/model.rb +16 -4
  20. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +2 -1
  21. data/lib/lutaml/model/schema/renderers.rb +2 -1
  22. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +6 -2
  23. data/lib/lutaml/model/schema/rnc_compiler.rb +3 -1
  24. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +35 -15
  25. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +4 -2
  26. data/lib/lutaml/model/schema/rng_compiler.rb +10 -5
  27. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +2 -1
  28. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +4 -2
  29. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +39 -13
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +29 -10
  31. data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +10 -9
  33. data/lib/lutaml/model/serialize/builder.rb +55 -0
  34. data/lib/lutaml/model/serialize/initialization.rb +12 -3
  35. data/lib/lutaml/model/serialize/model_import.rb +1 -0
  36. data/lib/lutaml/model/serialize.rb +5 -4
  37. data/lib/lutaml/model/union.rb +5 -2
  38. data/lib/lutaml/model/version.rb +1 -1
  39. data/lib/lutaml/xml/mapping_rule.rb +55 -1
  40. data/lib/lutaml/xml/model_transform.rb +53 -5
  41. data/lib/lutaml/xml/schema/xsd.rb +0 -2
  42. data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
  43. data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
  44. data/lib/lutaml/xml/transformation/ordered_applier.rb +99 -4
  45. data/lutaml-model.gemspec +4 -1
  46. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +26 -13
  47. data/spec/lutaml/model/attribute_spec.rb +8 -3
  48. data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
  49. data/spec/lutaml/model/defaults_spec.rb +45 -0
  50. data/spec/lutaml/model/json_spec.rb +1 -2
  51. data/spec/lutaml/model/ordered_content_spec.rb +18 -7
  52. data/spec/lutaml/model/serialize/builder_spec.rb +361 -0
  53. data/spec/lutaml/model/transform_apply_value_map_spec.rb +6 -3
  54. data/spec/lutaml/model/union_attribute_spec.rb +4 -2
  55. data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
  56. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +17 -6
  57. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +15 -6
  58. data/spec/lutaml/xml/w3c_types_spec.rb +3 -6
  59. metadata +7 -3
@@ -108,24 +108,20 @@ module Lutaml
108
108
  current = instance_variable_get(:"@#{name}") || []
109
109
  new_value = current.is_a?(Array) ? current + [value] : value
110
110
  instance_variable_set(:"@#{name}", new_value)
111
- # Track order for mixed_content serialization
112
- track_order(name, value, nil) if @__order_tracking__
111
+ record_mutation(name, value)
113
112
  value
114
113
  end
115
114
  end
116
115
  else
117
116
  # For non-collection attributes, getter accepts optional argument
118
- # for builder-style syntax: g.description(value) sets the value
117
+ # for builder-style syntax: g.description(value) sets the value.
118
+ # Tracking happens inside the setter, so no duplicate call here.
119
119
  define_method(name) do |*args|
120
120
  if args.empty?
121
121
  instance_variable_get(:"@#{name}")
122
122
  else
123
- # Builder-style: g.description(value) sets the value
124
- value = args.first
125
- public_send(:"#{name}=", value)
126
- # Track order for mixed_content serialization
127
- track_order(name, value, nil) if @__order_tracking__
128
- value
123
+ public_send(:"#{name}=", args.first)
124
+ args.first
129
125
  end
130
126
  end
131
127
  end
@@ -156,12 +152,16 @@ module Lutaml
156
152
  else
157
153
  instance_variable_set(:"@#{name}", value)
158
154
  end
155
+ # Track one entry per item so element_order reflects the
156
+ # number of <name> elements that will be emitted.
157
+ record_mutation_collection(name, value)
159
158
  end
160
159
  else
161
160
  define_method(:"#{name}=") do |value|
162
161
  value_set_for(name)
163
162
  value = attr.cast_value(value, lutaml_register)
164
163
  instance_variable_set(:"@#{name}", value)
164
+ record_mutation(name, value)
165
165
  end
166
166
  end
167
167
  end
@@ -188,6 +188,7 @@ module Lutaml
188
188
 
189
189
  attr = Attribute.new(name, type, options)
190
190
  @attributes[name] = attr
191
+ @merged_attributes_cache = nil
191
192
  define_attribute_methods(attr)
192
193
 
193
194
  attr
@@ -75,6 +75,61 @@ module Lutaml
75
75
  mapping&.ordered? || false
76
76
  end
77
77
 
78
+ # Whether this instance was constructed via a builder block and
79
+ # therefore records mutations into element_order. Parsed models
80
+ # and instances constructed without a block do not track; their
81
+ # element_order (if any) comes from the parser and is treated as
82
+ # the complete source of truth by the serializer.
83
+ # @return [Boolean]
84
+ def order_tracking_enabled?
85
+ @__order_tracking__ ? true : false
86
+ end
87
+
88
+ # Record a singular attribute mutation in element_order.
89
+ #
90
+ # No-op unless order tracking is enabled (i.e. the instance was
91
+ # constructed via a builder block on an ordered/mixed_content
92
+ # model). This is the single entry point for singular mutations
93
+ # and is called from generated setters and getter-with-arg paths
94
+ # so that direct setters (`x.foo = v`) and appender calls
95
+ # (`x.foo(v)`) behave identically.
96
+ #
97
+ # Returns `value` so generated setters preserve Ruby's setter
98
+ # contract: `obj.foo = v` evaluates to `v`. Callers like
99
+ # `obj.foo || (obj.foo = [])` depend on this.
100
+ #
101
+ # @param attribute_name [Symbol] The attribute being mutated
102
+ # @param value [Object, nil] The value being set; stored as text
103
+ # content for content-mapped attributes, ignored otherwise
104
+ # @return [Object] the passed value
105
+ def record_mutation(attribute_name, value = nil)
106
+ return value unless @__order_tracking__
107
+
108
+ track_order(attribute_name, value, nil)
109
+ value
110
+ end
111
+
112
+ # Record a collection-attribute mutation in element_order.
113
+ #
114
+ # Emits one entry per item so that element_order length matches
115
+ # the number of serialized child elements. No-op when tracking
116
+ # is disabled, when the value is nil/empty, or when the
117
+ # collection's frozen sentinel is preserved (no real data).
118
+ #
119
+ # Returns `value` so generated setters preserve Ruby's setter
120
+ # contract (see {record_mutation}).
121
+ #
122
+ # @param attribute_name [Symbol] The collection attribute
123
+ # @param value [Object, nil] The value assigned to the collection
124
+ # @return [Object] the passed value
125
+ def record_mutation_collection(attribute_name, value)
126
+ return value unless @__order_tracking__
127
+ return value if value.nil? || Lutaml::Model::Utils.uninitialized?(value)
128
+
129
+ Array(value).each { |item| track_order(attribute_name, item, nil) }
130
+ value
131
+ end
132
+
78
133
  private
79
134
 
80
135
  # Intercept method calls to track order for mixed_content
@@ -115,13 +115,18 @@ module Lutaml
115
115
  # Get all attributes for this model
116
116
  #
117
117
  # Merges class-level attributes with register-specific attributes.
118
+ # Memoized per register: this is on the per-element deserialization
119
+ # hot path, and the merged hash is stable until an attribute, import
120
+ # or register record mutates (those clear @merged_attributes_cache).
118
121
  #
119
122
  # @param register [Symbol, nil] The register context
120
123
  # @return [Hash] The attributes hash
121
124
  def attributes(register = nil)
122
125
  ensure_imports!(register) if finalized?
123
126
  if @register_records&.any?
124
- @attributes.merge(@register_records[extract_register_id(register)][:attributes])
127
+ register_id = extract_register_id(register)
128
+ (@merged_attributes_cache ||= {})[register_id] ||=
129
+ @attributes.merge(@register_records[register_id][:attributes])
125
130
  else
126
131
  @attributes
127
132
  end
@@ -183,6 +188,9 @@ module Lutaml
183
188
  GlobalContext.resolver.clear_cache(register_id)
184
189
  end
185
190
 
191
+ # Clear memoized attribute merge (see .attributes)
192
+ @merged_attributes_cache = nil
193
+
186
194
  # Clear per-Attribute type caches (stale entries from GC'd TypeContext objects)
187
195
  class_attributes.each_value(&:clear_type_cache)
188
196
  @register_records&.each_value do |record|
@@ -370,7 +378,6 @@ module Lutaml
370
378
  instance_variable_get(:"@#{name}")
371
379
  else
372
380
  public_send(:"#{name}=", args.first)
373
- track_order(name, args.first, nil) if @__order_tracking__
374
381
  args.first
375
382
  end
376
383
  end
@@ -380,6 +387,7 @@ module Lutaml
380
387
  reg_attr = resolve_register_attr(name)
381
388
  value = reg_attr.cast_value(value, lutaml_register)
382
389
  instance_variable_set(:"@#{name}", value)
390
+ record_mutation(name, value)
383
391
  end
384
392
  end
385
393
 
@@ -395,7 +403,7 @@ module Lutaml
395
403
  current = [] if current.equal?(LAZY_EMPTY_COLLECTION)
396
404
  new_value = current.is_a?(Array) ? current + [value] : value
397
405
  instance_variable_set(:"@#{name}", new_value)
398
- track_order(name, value, nil) if @__order_tracking__
406
+ record_mutation(name, value)
399
407
  value
400
408
  end
401
409
  end
@@ -411,6 +419,7 @@ module Lutaml
411
419
  else
412
420
  instance_variable_set(:"@#{name}", value)
413
421
  end
422
+ record_mutation_collection(name, value)
414
423
  end
415
424
  end
416
425
 
@@ -121,6 +121,7 @@ module Lutaml
121
121
  @register_records[register_id][:choice_attributes].concat(
122
122
  deep_duplicate_choice_attributes(model, register_id),
123
123
  )
124
+ @merged_attributes_cache = nil
124
125
  end
125
126
  private :register_only_import_model_attributes
126
127
 
@@ -175,18 +175,19 @@ module Lutaml
175
175
  @using_default[attribute_name]
176
176
  end
177
177
 
178
- # rubocop:disable Style/ArgumentsForwarding -- anonymous * requires Ruby 3.2+, but required_ruby_version >= 3.0
179
- def method_missing(method_name, *args)
178
+ def method_missing(method_name, *, &)
180
179
  if method_name.to_s.end_with?("=") && attribute_exist?(method_name)
181
180
  define_singleton_method(method_name) do |value|
182
181
  instance_variable_set(:"@#{method_name.to_s.chomp('=')}", value)
183
182
  end
184
- send(method_name, *args)
183
+ send(method_name, *)
184
+ elsif ::Lutaml::Model::Attribute.default_evaluation? &&
185
+ self.class.respond_to?(method_name)
186
+ self.class.public_send(method_name, *, &)
185
187
  else
186
188
  super
187
189
  end
188
190
  end
189
- # rubocop:enable Style/ArgumentsForwarding
190
191
 
191
192
  def respond_to_missing?(method_name, include_private = false)
192
193
  (method_name.to_s.end_with?("=") && attribute_exist?(method_name)) ||
@@ -131,7 +131,8 @@ module Lutaml
131
131
 
132
132
  def validate_members!(members)
133
133
  unless members.is_a?(::Array) && !members.empty?
134
- raise ArgumentError, "union type must be a non-empty array of types"
134
+ raise ArgumentError,
135
+ "union type must be a non-empty array of types"
135
136
  end
136
137
 
137
138
  resolved = members.map { |member| resolve_member!(member) }
@@ -166,7 +167,9 @@ module Lutaml
166
167
  return :no_match if keys.nil? || keys.empty?
167
168
 
168
169
  field_names = member_field_names(member, format, register)
169
- return :no_match unless keys.all? { |key| field_names.include?(key) }
170
+ return :no_match unless keys.all? do |key|
171
+ field_names.include?(key)
172
+ end
170
173
 
171
174
  # Plain-Ruby assignment (format nil) builds straight from the
172
175
  # attribute hash, mirroring how non-union model attributes accept
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.18"
5
+ VERSION = "0.9.1"
6
6
  end
7
7
  end
@@ -183,6 +183,34 @@ module Lutaml
183
183
  result
184
184
  end
185
185
 
186
+ # Match names an attribute rule consumes during parsing, mirroring how
187
+ # serialization qualifies the same attribute (resolve_namespace):
188
+ # a rule serialized with a prefix matches only attributes in its
189
+ # namespace; a rule serialized unprefixed matches only namespace-less
190
+ # attributes.
191
+ #
192
+ # Performance: memoized on the rule with zero-allocation cache hits.
193
+ # The inputs are constant for a given rule, register and parent model,
194
+ # so every element of a collection reuses the first resolution.
195
+ def attribute_match_names(attr, register, parent_ns_class, form_default,
196
+ default_namespace)
197
+ if defined?(@_amn_names) && @_amn_register == register &&
198
+ @_amn_parent_ns == parent_ns_class &&
199
+ @_amn_form_default == form_default &&
200
+ @_amn_default_ns == default_namespace
201
+ return @_amn_names
202
+ end
203
+
204
+ result = compute_attribute_match_names(attr, register, parent_ns_class,
205
+ form_default, default_namespace)
206
+ @_amn_register = register
207
+ @_amn_parent_ns = parent_ns_class
208
+ @_amn_form_default = form_default
209
+ @_amn_default_ns = default_namespace
210
+ @_amn_names = result
211
+ result
212
+ end
213
+
186
214
  def namespaced_name(parent_namespace = nil, name = self.name)
187
215
  if name.to_s == "lang"
188
216
  ::Lutaml::Model::Utils.blank?(prefix) ? name.to_s : "#{prefix}:#{name}"
@@ -274,6 +302,30 @@ module Lutaml
274
302
 
275
303
  private
276
304
 
305
+ # Extracted body of attribute_match_names for caching
306
+ def compute_attribute_match_names(attr, register, parent_ns_class,
307
+ form_default, default_namespace)
308
+ ns_info = resolve_namespace(
309
+ attr: attr,
310
+ register: register,
311
+ parent_ns_class: parent_ns_class,
312
+ form_default: form_default,
313
+ )
314
+ uri = ns_info[:uri]
315
+ # Schema-level attribute_form_default keeps the historical plain-name
316
+ # parse: dependents (e.g. ogc-gml) declare :qualified but their
317
+ # documents and expectations rely on unprefixed attributes parsing.
318
+ # Explicit namespaces, type namespaces and explicit form: rules
319
+ # mirror serialization strictly.
320
+ strict_match = uri && ns_info[:prefix] &&
321
+ !ns_info[:unqualified_same_ns] && !ns_info[:via_schema_default]
322
+ if strict_match
323
+ ["#{uri}:#{name}"].freeze
324
+ else
325
+ namespaced_names(default_namespace)
326
+ end
327
+ end
328
+
277
329
  # Extracted body of namespaced_names for caching
278
330
  def compute_namespaced_names(parent_namespace)
279
331
  if multiple_mappings?
@@ -427,7 +479,9 @@ form_default = :unqualified)
427
479
  # When schema specifies attributeFormDefault="qualified", attributes
428
480
  # must be qualified (prefixed) with the parent element's namespace
429
481
  if form_default == :qualified && parent_ns_class
430
- return build_namespace_result_from_class(parent_ns_class)
482
+ result = build_namespace_result_from_class(parent_ns_class)
483
+ result[:via_schema_default] = true
484
+ return result
431
485
  end
432
486
 
433
487
  # 4. No namespace (W3C default for unprefixed attributes)
@@ -509,8 +509,13 @@ _effective_register)
509
509
  local_name = rn[(last_colon_index + 1)..]
510
510
 
511
511
  matched_attr = doc.root.attributes.values.find do |attr|
512
- # Match by the attribute's namespaced_name, unprefixed_name, or by extracting
513
- # local name from the attribute's key (handles unresolved prefixes).
512
+ # A namespace-less, prefix-less attribute cannot belong to the
513
+ # rule's namespace, so it must not satisfy the fallback
514
+ # (lutaml-model#744). Attributes with undeclared prefixes keep
515
+ # their prefix in the raw name and stay eligible below.
516
+ next false if attr.namespace.nil? &&
517
+ attr.namespace_prefix.nil? && !attr.name.include?(":")
518
+
514
519
  attr.namespaced_name == local_name ||
515
520
  attr.unprefixed_name == local_name ||
516
521
  (attr.namespace.nil? && ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name)
@@ -544,9 +549,14 @@ _effective_register)
544
549
  rule_namespace = rule_namespace_set ? rule.namespace : nil
545
550
  options[:default_namespace]
546
551
 
547
- # Enhanced namespace resolution with type support
548
- rule_names = resolve_rule_names_with_type(rule, attr, options,
549
- effective_register, attr_type)
552
+ rule_names = if rule.attribute?
553
+ resolve_attribute_rule_names(rule, attr, options,
554
+ effective_register,
555
+ instance, instance_is_serialize)
556
+ else
557
+ resolve_rule_names_with_type(rule, attr, options,
558
+ effective_register, attr_type)
559
+ end
550
560
 
551
561
  return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
552
562
 
@@ -879,6 +889,44 @@ effective_register = lutaml_register)
879
889
  end
880
890
  end
881
891
 
892
+ # Match names for an attribute rule, mirroring how serialization
893
+ # qualifies the same attribute (MappingRule#attribute_match_names):
894
+ # a rule serialized with a prefix matches only attributes in its
895
+ # namespace; a rule serialized unprefixed matches only namespace-less
896
+ # attributes.
897
+ def resolve_attribute_rule_names(rule, attr, options, effective_register,
898
+ instance, instance_is_serialize)
899
+ return rule.namespaced_names(options[:default_namespace]) if attr.nil? ||
900
+ rule.multiple_mappings?
901
+
902
+ if instance_is_serialize
903
+ parent_ns_class, form_default = parent_attribute_context(instance)
904
+ else
905
+ parent_ns_class = nil
906
+ form_default = :unqualified
907
+ end
908
+ rule.attribute_match_names(attr, effective_register, parent_ns_class,
909
+ form_default,
910
+ options[:default_namespace])
911
+ end
912
+
913
+ # [namespace_class, attribute_form_default] for the instance's model,
914
+ # keyed on the model class. Attribute rules of one element share the
915
+ # model, so a 1-entry cache avoids a mappings_for lookup per rule.
916
+ def parent_attribute_context(instance)
917
+ klass = instance.class
918
+ if @parent_ns_klass == klass && @parent_ns_value
919
+ return @parent_ns_value
920
+ end
921
+
922
+ ns_class = klass.mappings_for(:xml)&.namespace_class
923
+ value = [ns_class,
924
+ ns_class&.attribute_form_default || :unqualified]
925
+ @parent_ns_klass = klass
926
+ @parent_ns_value = value
927
+ value
928
+ end
929
+
882
930
  # Resolve rule names with type namespace support
883
931
  #
884
932
  # @param rule [MappingRule] the mapping rule
@@ -141,9 +141,7 @@ module Lutaml
141
141
  # Trigger autoloads for all XSD model classes to ensure they are
142
142
  # registered before parsing begins. This is necessary because
143
143
  # type resolution during parsing looks up classes in the register.
144
- # rubocop:disable Style/RedundantFetchOverride
145
144
  XSD_AUTOLOAD_CLASSES.each { |c| const_get(c) unless c == :VERSION }
146
- # rubocop:enable Style/RedundantFetchOverride
147
145
 
148
146
  # Validate XSD schema structure before parsing (unless disabled)
149
147
  if validate_schema && !nested_schema
@@ -410,7 +410,8 @@ attr_name)
410
410
  def self.get_attribute_xsd_type(attr, attr_type, register,
411
411
  _mapping_rule = nil)
412
412
  if attr.union?
413
- raise Lutaml::Model::UnionSchemaUnsupportedError.new(attr.name, "XSD")
413
+ raise Lutaml::Model::UnionSchemaUnsupportedError.new(attr.name,
414
+ "XSD")
414
415
  end
415
416
 
416
417
  # 1. Check for deprecated attribute-level xsd_type override
@@ -143,7 +143,8 @@ parent_element_form_default)
143
143
  # Get transformation for the actual class. Unions resolve the member
144
144
  # from the value's own class, like polymorphism.
145
145
  child_transformation = if union
146
- value.class.transformation_for(:xml, register)
146
+ value.class.transformation_for(:xml,
147
+ register)
147
148
  elsif is_polymorphic || is_polymorphic_subtype
148
149
  actual_class.transformation_for(:xml,
149
150
  register)
@@ -297,7 +297,16 @@ _options)
297
297
  end
298
298
  end
299
299
 
300
- # Apply remaining rules (attributes and content/raw)
300
+ # Apply remaining rules (attributes, content/raw, and any element
301
+ # rules not represented in element_order).
302
+ #
303
+ # The element-type skip that used to live here caused silent data
304
+ # loss whenever an element-typed attribute was missing from
305
+ # element_order (e.g. when a direct setter forgot to call
306
+ # track_order). After the builder-side fix all mutation paths
307
+ # record into element_order, so this branch is a defense-in-depth
308
+ # safety net: emit any element-typed rule whose value would
309
+ # otherwise vanish.
301
310
  #
302
311
  # @param root [XmlElement] Root element
303
312
  # @param model_instance [Object] The model instance
@@ -317,21 +326,107 @@ compiled_rules, mapping, processed_text_nodes)
317
326
  compiled_rules
318
327
  end
319
328
 
329
+ emitted_counts = element_order_coverage(model_instance,
330
+ compiled_rules)
331
+
320
332
  rules_to_apply.each do |rule|
321
- next if rule.option(:mapping_type) == :element
333
+ mapping_type = rule.option(:mapping_type)
322
334
 
323
- # Skip content rules if we processed text nodes from element_order
324
- if %i[content raw].include?(rule.option(:mapping_type)) &&
335
+ # Skip content/raw if mixed or text nodes were processed
336
+ if %i[content raw].include?(mapping_type) &&
325
337
  (mapping&.mixed_content? || processed_text_nodes)
326
338
  next
327
339
  end
328
340
 
341
+ if mapping_type == :element &&
342
+ element_rule_already_emitted?(rule, model_instance,
343
+ emitted_counts)
344
+ next
345
+ end
346
+
329
347
  next unless valid_mapping?(rule, options)
330
348
 
331
349
  yield(:apply_rule, rule, nil) if block_given?
332
350
  end
333
351
  end
334
352
 
353
+ # Count, per element-typed rule, how many entries in element_order
354
+ # already cover it. Returns a Hash keyed by CompiledRule identity.
355
+ #
356
+ # @param model_instance [Object] The model instance
357
+ # @param compiled_rules [Array<CompiledRule>] The compiled rules
358
+ # @return [Hash<CompiledRule, Integer>] Coverage counts
359
+ def element_order_coverage(model_instance, compiled_rules)
360
+ counts = ::Hash.new(0)
361
+ return counts unless model_instance.respond_to?(:element_order)
362
+ return counts unless (order = model_instance.element_order)
363
+
364
+ element_rules = compiled_rules.select do |r|
365
+ r.is_a?(::Lutaml::Model::CompiledRule) &&
366
+ r.option(:mapping_type) == :element
367
+ end
368
+
369
+ order.each do |object|
370
+ next unless object.type == "Element"
371
+
372
+ object_ns_uri = object.namespace_uri
373
+ matched = element_rules.find do |r|
374
+ matches_element_rule?(r, object.name, object_ns_uri)
375
+ end
376
+ counts[matched] += 1 if matched
377
+ end
378
+
379
+ counts
380
+ end
381
+
382
+ # Whether an element-typed rule has already been fully emitted
383
+ # via element_order (or should not be emitted at all by the safety
384
+ # net). Returns true when:
385
+ # - the model was not constructed via builder block
386
+ # (`@__order_tracking__` is nil/false): parsed models trust
387
+ # element_order as the complete source of truth, so the safety
388
+ # net must not second-guess it by emitting defaults/uninitialized
389
+ # values that the standard path would otherwise have skipped.
390
+ # - the standard skip logic says the value should be skipped
391
+ # (handles defaults, render_nil/render_empty, value_map, etc.)
392
+ # - or the rule was fully covered by element_order entries
393
+ #
394
+ # The safety net targets the bug class where a builder-block
395
+ # construction bypassed element_order tracking (e.g. via a
396
+ # mutation path that forgot to call record_mutation). After
397
+ # Option A, all setter/getter paths record into element_order,
398
+ # so this branch is defense-in-depth rather than the common
399
+ # path.
400
+ #
401
+ # @param rule [CompiledRule] The element rule
402
+ # @param model_instance [Object] The model instance
403
+ # @param emitted_counts [Hash<CompiledRule, Integer>] Coverage map
404
+ # @return [Boolean]
405
+ def element_rule_already_emitted?(rule, model_instance,
406
+ emitted_counts)
407
+ return true unless model_order_tracking_enabled?(model_instance)
408
+
409
+ value = extract_ordered_rule_value(rule, model_instance)
410
+ return true if should_skip_value?(value, rule, model_instance)
411
+
412
+ emitted = emitted_counts[rule]
413
+ if rule.collection?
414
+ value_length = value.respond_to?(:length) ? value.length : 0
415
+ emitted >= value_length
416
+ else
417
+ emitted.positive?
418
+ end
419
+ end
420
+
421
+ # Whether the model was constructed via a builder block and thus
422
+ # has order tracking enabled. Only such models are candidates for
423
+ # the safety net; parsed models trust element_order as-is.
424
+ def model_order_tracking_enabled?(model_instance)
425
+ return false unless model_instance.respond_to?(:order_tracking_enabled?)
426
+
427
+ model_instance.order_tracking_enabled?
428
+ end
429
+
335
430
  # Sort compiled rules so attribute rules follow the captured attribute_order.
336
431
  # Non-attribute rules (content, raw) maintain their original position.
337
432
  #
data/lutaml-model.gemspec CHANGED
@@ -18,7 +18,10 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.bindir = "exe"
20
20
  spec.require_paths = ["lib"]
21
- spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
21
+ # lib/ uses anonymous argument forwarding in call position, which parses
22
+ # only on Ruby 3.2+ (verified: 6 files fail to parse on 3.1). Without this
23
+ # floor, bundler resolves 0.8.x on 3.1 and it dies at require time.
24
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
22
25
 
23
26
  # Specify which files should be added to the gem when it is released.
24
27
  # The `git ls-files -z` loads the files in the