lutaml-model 0.8.22 → 0.8.24

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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/performance.yml +1 -1
  3. data/Gemfile +6 -0
  4. data/README.adoc +16 -7
  5. data/docs/_guides/index.adoc +4 -0
  6. data/docs/_guides/native-engines.adoc +61 -0
  7. data/docs/_guides/xml-mapping.adoc +31 -0
  8. data/docs/_pages/configuration.adoc +11 -7
  9. data/docs/_pages/serialization_adapters.adoc +14 -5
  10. data/lib/compat/opal/lutaml_model_boot.rb +2 -0
  11. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  12. data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
  13. data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
  14. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  15. data/lib/lutaml/json/adapter.rb +1 -0
  16. data/lib/lutaml/json/format.rb +1 -1
  17. data/lib/lutaml/json/generator_options.rb +62 -0
  18. data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
  19. data/lib/lutaml/jsonld/adapter.rb +12 -4
  20. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  21. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
  22. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
  23. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  24. data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
  25. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  26. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  27. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  28. data/lib/lutaml/key_value/transform.rb +82 -0
  29. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  30. data/lib/lutaml/key_value/transformation.rb +25 -1
  31. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  32. data/lib/lutaml/model/attribute.rb +31 -6
  33. data/lib/lutaml/model/collection.rb +5 -0
  34. data/lib/lutaml/model/configuration.rb +5 -0
  35. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  36. data/lib/lutaml/model/serialization_adapter.rb +1 -1
  37. data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
  38. data/lib/lutaml/model/serialize.rb +18 -0
  39. data/lib/lutaml/model/toml.rb +10 -6
  40. data/lib/lutaml/model/transformation.rb +5 -0
  41. data/lib/lutaml/model/type/date_time.rb +2 -2
  42. data/lib/lutaml/model/type/value.rb +24 -2
  43. data/lib/lutaml/model/utils.rb +6 -5
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  46. data/lib/lutaml/toml/adapter.rb +1 -0
  47. data/lib/lutaml/toml/format.rb +1 -1
  48. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  49. data/lib/lutaml/xml/adapter_element.rb +111 -3
  50. data/lib/lutaml/xml/builder/base.rb +3 -1
  51. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  52. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  53. data/lib/lutaml/xml/builder.rb +1 -0
  54. data/lib/lutaml/xml/format.rb +4 -1
  55. data/lib/lutaml/xml/leptris/element.rb +34 -0
  56. data/lib/lutaml/xml/mapping.rb +24 -0
  57. data/lib/lutaml/xml/model_transform.rb +52 -1
  58. data/lib/lutaml/xml/plan_compiler.rb +139 -0
  59. data/lib/lutaml/xml/plan_hydrator.rb +80 -0
  60. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  61. data/lib/lutaml/xml/transformation.rb +29 -0
  62. data/lib/lutaml/xml/xml_element.rb +6 -2
  63. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
  64. data/lib/lutaml/yaml/adapter.rb +5 -0
  65. data/lib/lutaml/yaml/format.rb +1 -1
  66. data/lib/tasks/performance.rake +5 -0
  67. data/lib/tasks/performance_comparator.rb +19 -3
  68. data/lib/tasks/performance_probe.rake +33 -0
  69. data/lib/tasks/performance_probe.rb +106 -0
  70. data/lib/tasks/performance_probe_xml.rb +54 -0
  71. data/lutaml-model.gemspec +1 -1
  72. data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
  73. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  74. data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
  75. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  76. data/spec/lutaml/model/collection_spec.rb +1 -1
  77. data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
  78. data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
  79. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  80. data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
  81. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  82. data/spec/lutaml/model/toml_spec.rb +36 -1
  83. data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
  84. data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
  85. data/spec/lutaml/xml/xml_spec.rb +13 -0
  86. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  87. data/spec/spec_helper.rb +7 -1
  88. metadata +29 -4
@@ -7,6 +7,7 @@ module Lutaml
7
7
  module Toml
8
8
  Lutaml::Model::RuntimeCompatibility.define_native_aliases(
9
9
  self,
10
+ TeptrisAdapter: "::Lutaml::Toml::Adapter::TeptrisAdapter",
10
11
  TomlibAdapter: "::Lutaml::Toml::Adapter::TomlibAdapter",
11
12
  TomlRbAdapter: "::Lutaml::Toml::Adapter::TomlRbAdapter",
12
13
  )
@@ -18,15 +19,18 @@ module Lutaml
18
19
  def self.detect_toml_adapter
19
20
  return nil if Lutaml::Model.opal?
20
21
 
21
- # Skip tomlib on Windows entirely due to segfault issues
22
- if Lutaml::Model::RuntimeCompatibility.windows?
23
- return :toml_rb if Lutaml::Model::Utils.safe_load("toml-rb", :TomlRb)
22
+ # teptris 0.2.12+ parses ~3x and dumps ~8x faster than tomlib
23
+ # and ships prebuilts for every platform (mingw-ucrt restored
24
+ # in 0.2.4+) — native TOML on Windows retires the tomlib
25
+ # segfault workaround (previously pure-Ruby toml-rb only).
26
+ return :teptris if Lutaml::Model::Utils.safe_load("teptris", :Teptris)
24
27
 
25
- return nil
28
+ # tomlib segfaults on Windows when parsing invalid TOML
29
+ if !Lutaml::Model::RuntimeCompatibility.windows? &&
30
+ Lutaml::Model::Utils.safe_load("tomlib", :Tomlib)
31
+ return :tomlib
26
32
  end
27
33
 
28
- # On non-Windows, prefer tomlib
29
- return :tomlib if Lutaml::Model::Utils.safe_load("tomlib", :Tomlib)
30
34
  return :toml_rb if Lutaml::Model::Utils.safe_load("toml-rb", :TomlRb)
31
35
 
32
36
  nil
@@ -37,9 +37,14 @@ module Lutaml
37
37
  @format = format
38
38
  @register = register
39
39
  @compiled_rules = compile_rules(mapping_dsl)
40
+ after_compile
40
41
  freeze
41
42
  end
42
43
 
44
+ # Hook for format subclasses to derive rule-set-level data after
45
+ # compilation, before the transformation is frozen and shared.
46
+ def after_compile; end
47
+
43
48
  # Transform a model instance into format-specific representation
44
49
  #
45
50
  # @abstract Subclasses must implement this method
@@ -1,7 +1,7 @@
1
- require "date"
2
-
3
1
  # frozen_string_literal: true
4
2
 
3
+ require "date"
4
+
5
5
  module Lutaml
6
6
  module Model
7
7
  module Type
@@ -2,6 +2,16 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
+ # Isolated holder for format_type_serializer_for's memo: Type::Value
6
+ # can be frozen by suites, which would freeze an ivar Hash with it.
7
+ SERIALIZER_LOOKUP_CACHE = ::Class.new do
8
+ class << self
9
+ def cache
10
+ @cache ||= {}
11
+ end
12
+ end
13
+ end.cache
14
+
5
15
  module Type
6
16
  # Base class for all value types
7
17
  class Value
@@ -35,14 +45,26 @@ from: nil)
35
45
  # @param type_class [Class] the type class to look up
36
46
  # @return [Hash, nil] { to: Proc, from: Proc } or nil
37
47
  def format_type_serializer_for(format, type_class)
48
+ # Hot path: memoize per [format, resolved class] — the hierarchy
49
+ # walk below only runs on a miss. Format serializers register at
50
+ # load time, so a hit is stable.
51
+ cache = SERIALIZER_LOOKUP_CACHE
52
+ key = [format, type_class]
53
+ return cache[key] if cache.key?(key)
54
+
38
55
  klass = type_class
56
+ found = nil
39
57
  while klass && klass <= Value
40
58
  s = @format_type_serializers[[format, klass]]
41
- return s if s
59
+ if s
60
+ found = s
61
+ break
62
+ end
42
63
 
43
64
  klass = klass.superclass
44
65
  end
45
- nil
66
+ cache[key] = found
67
+ found
46
68
  end
47
69
  end
48
70
 
@@ -121,11 +121,12 @@ module Lutaml
121
121
  end
122
122
 
123
123
  def blank?(value)
124
- case value
125
- when ::String, ::Array, ::Hash then value.empty?
126
- when ::NilClass then true
127
- else false
128
- end
124
+ # Hot path: the nil test is a pointer compare, and nil dominates
125
+ # in optional-field-heavy documents.
126
+ return true if value.nil?
127
+ return false unless value.is_a?(::String) || value.is_a?(::Array) || value.is_a?(::Hash)
128
+
129
+ value.empty?
129
130
  end
130
131
 
131
132
  def empty_collection?(collection)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.22"
5
+ VERSION = "0.8.24"
6
6
  end
7
7
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../key_value/adapter/toml/teptris_adapter"
4
+
5
+ module Lutaml
6
+ module Toml
7
+ module Adapter
8
+ # The implementation lives in the KeyValue namespace (the live
9
+ # surface for adapter resolution); this alias keeps the legacy
10
+ # Lutaml::Toml::Adapter surface complete.
11
+ TeptrisAdapter = Lutaml::KeyValue::Adapter::Toml::TeptrisAdapter
12
+ end
13
+ end
14
+ end
@@ -11,6 +11,7 @@ module Lutaml
11
11
  autoload :Transform, "#{__dir__}/adapter/transform"
12
12
  Lutaml::Model::RuntimeCompatibility.autoload_native(
13
13
  self,
14
+ TeptrisAdapter: "#{__dir__}/adapter/teptris_adapter",
14
15
  TomlRbAdapter: "#{__dir__}/adapter/toml_rb_adapter",
15
16
  TomlibAdapter: "#{__dir__}/adapter/tomlib_adapter",
16
17
  )
@@ -35,7 +35,7 @@ Lutaml::Model::FormatRegistry.register(
35
35
  nil
36
36
  else
37
37
  {
38
- available: %i[tomlib toml_rb],
38
+ available: %i[teptris tomlib toml_rb],
39
39
  default: Lutaml::Model::RuntimeCompatibility.windows? ? :toml_rb : :tomlib,
40
40
  }
41
41
  end,
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml/adapter/leptris"
4
+
5
+ module Lutaml
6
+ module Xml
7
+ module Adapter
8
+ class LeptrisAdapter < BaseAdapter
9
+ MOXML_ADAPTER = Moxml::Adapter::Leptris
10
+ BUILDER_CLASS = Builder::Leptris
11
+ PARSED_ELEMENT_CLASS = Lutaml::Xml::LeptrisElement
12
+ end
13
+ end
14
+ end
15
+ end
@@ -22,7 +22,11 @@ module Lutaml
22
22
  text = case node
23
23
  when Moxml::Element
24
24
  namespace_name = node.namespace&.prefix
25
- ns_defs = node.namespaces
25
+ # moxml >= 0.5.33 contract: #namespaces is the full
26
+ # in-scope map (parity with Nokogiri, #198/#201); the
27
+ # own-declarations shape this parse path needs is
28
+ # #namespace_definitions.
29
+ ns_defs = node.namespace_definitions
26
30
 
27
31
  has_empty_xmlns = ns_defs.any? do |ns|
28
32
  ns.prefix.nil? && ns.uri == ""
@@ -172,14 +176,118 @@ module Lutaml
172
176
  def parse_children(node, default_namespace: nil)
173
177
  return [] unless node.children
174
178
 
179
+ # Non-element children (text/cdata/comment/PI) stay RAW moxml
180
+ # nodes in the list — the hot scalar parse path reads text off
181
+ # the element (inner_text) and never touches their wrappers, so
182
+ # wrapping every text node eagerly paid half the parse tree for
183
+ # nothing. #children wraps them on first access; document order
184
+ # is preserved because they sit at their original positions.
175
185
  node.children.filter_map do |child|
176
186
  next if (child.is_a?(Moxml::Text) || child.is_a?(Moxml::Cdata)) && child.content.empty?
177
187
 
178
- self.class.new(child, parent: self,
179
- default_namespace: default_namespace)
188
+ if child.is_a?(Moxml::Element)
189
+ self.class.new(child, parent: self,
190
+ default_namespace: default_namespace)
191
+ else
192
+ child
193
+ end
180
194
  end
181
195
  end
182
196
 
197
+ public
198
+
199
+ def children
200
+ unless @non_element_children_wrapped
201
+ @children.map! do |child|
202
+ if child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
203
+ self.class.new(child, parent: self)
204
+ else
205
+ child
206
+ end
207
+ end
208
+ @non_element_children_wrapped = true
209
+ end
210
+ @children
211
+ end
212
+
213
+ def children=(new_children)
214
+ @non_element_children_wrapped = true
215
+ super
216
+ end
217
+
218
+ def element_children
219
+ return @element_children if defined?(@element_children)
220
+
221
+ @element_children = @children.reject do |child|
222
+ raw_non_element_child?(child) ||
223
+ (child.is_a?(XmlElement) &&
224
+ (child.text? || child.processing_instruction?))
225
+ end
226
+ end
227
+
228
+ def raw_non_element_child?(child)
229
+ child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
230
+ end
231
+
232
+ # Order consumes raw non-element children inline: wrapping them
233
+ # just to read .text/.content would materialize the whole lazy
234
+ # layer on every parse.
235
+ def order
236
+ return @order_cache if @order_cache
237
+
238
+ @order_cache = @children.filter_map do |child|
239
+ case child
240
+ when Moxml::Cdata
241
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
242
+ text_content: child.content,
243
+ node_type: :cdata)
244
+ when Moxml::Text
245
+ next if child.content.nil?
246
+
247
+ Lutaml::Xml::Element.new("Text", "text",
248
+ text_content: child.content,
249
+ node_type: :text)
250
+ when Moxml::Comment
251
+ Lutaml::Xml::Element.new("Comment", "comment",
252
+ text_content: child.content,
253
+ node_type: :comment)
254
+ when Moxml::ProcessingInstruction
255
+ Lutaml::Xml::Element.new("ProcessingInstruction",
256
+ child.target,
257
+ text_content: child.content.to_s.sub(/\A\s+/, ""),
258
+ node_type: :processing_instruction)
259
+ else
260
+ next if child.is_a?(Moxml::Node)
261
+
262
+ if child.cdata?
263
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
264
+ text_content: child.text,
265
+ node_type: :cdata)
266
+ elsif child.text?
267
+ next if child.text.nil?
268
+
269
+ Lutaml::Xml::Element.new("Text", "text",
270
+ text_content: child.text,
271
+ node_type: :text)
272
+ elsif child.comment?
273
+ Lutaml::Xml::Element.new("Comment", "comment",
274
+ text_content: child.text,
275
+ node_type: :comment)
276
+ elsif child.processing_instruction?
277
+ Lutaml::Xml::Element.new("ProcessingInstruction",
278
+ child.unprefixed_name,
279
+ text_content: child.text,
280
+ node_type: :processing_instruction)
281
+ else
282
+ Lutaml::Xml::Element.new("Element", child.unprefixed_name,
283
+ node_type: :element,
284
+ namespace_uri: child.namespace_uri,
285
+ namespace_prefix: child.namespace_prefix)
286
+ end
287
+ end
288
+ end.each(&:freeze).freeze
289
+ end
290
+
183
291
  def add_namespaces_from_defs(ns_defs, is_root: false)
184
292
  has_default_xmlns = is_root || ns_defs.any? { |ns| ns.prefix.nil? }
185
293
 
@@ -223,7 +223,9 @@ module Lutaml
223
223
  ns = new_el.in_scope_namespaces.find { |n| n.prefix == prefix }
224
224
  new_el.namespace = ns if ns
225
225
  elsif !prefix_unset && prefix.nil?
226
- new_el.namespace = nil
226
+ # Skip the no-op clear: namespace-less elements pay the
227
+ # undeclare path on some engines (leptris) for nothing.
228
+ new_el.namespace = nil unless new_el.namespace.nil?
227
229
  end
228
230
  end
229
231
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Builder
6
+ class Leptris < Base
7
+ def self.moxml_backend
8
+ :leptris
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -4,6 +4,9 @@ module Lutaml
4
4
  module Xml
5
5
  module Builder
6
6
  class Nokogiri < Base
7
+ def self.moxml_backend
8
+ :nokogiri
9
+ end
7
10
  end
8
11
  end
9
12
  end
@@ -11,6 +11,7 @@ module Lutaml
11
11
  self,
12
12
  Nokogiri: "#{__dir__}/builder/nokogiri",
13
13
  Ox: "#{__dir__}/builder/ox",
14
+ Leptris: "#{__dir__}/builder/leptris",
14
15
  )
15
16
  end
16
17
  end
@@ -21,6 +21,8 @@ end
21
21
 
22
22
  module Lutaml
23
23
  module Xml
24
+ autoload :PlanCompiler, "#{__dir__}/plan_compiler"
25
+ autoload :PlanHydrator, "#{__dir__}/plan_hydrator"
24
26
  # Error module for XML-specific errors
25
27
  module Error
26
28
  autoload :XmlError, "#{__dir__}/error/xml_error"
@@ -161,6 +163,7 @@ module Lutaml
161
163
  NokogiriElement: "#{__dir__}/nokogiri/element",
162
164
  Nokogiri: "#{__dir__}/nokogiri",
163
165
  Ox: "#{__dir__}/ox",
166
+ LeptrisElement: "#{__dir__}/leptris/element",
164
167
  )
165
168
  end
166
169
  end
@@ -192,7 +195,7 @@ Lutaml::Model::FormatRegistry.register(
192
195
  }
193
196
  else
194
197
  {
195
- available: %i[nokogiri ox oga rexml],
198
+ available: %i[nokogiri ox oga rexml leptris],
196
199
  default: :nokogiri,
197
200
  }
198
201
  end,
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ class LeptrisElement < AdapterElement
6
+ def initialize(node, parent: nil, default_namespace: nil)
7
+ @raw_text = node.raw_content if node.is_a?(Moxml::Text)
8
+ super
9
+ end
10
+
11
+ private
12
+
13
+ def adapter_class
14
+ Lutaml::Xml::Adapter::LeptrisAdapter
15
+ end
16
+
17
+ def build_text_for_xml
18
+ @raw_text || @text
19
+ end
20
+
21
+ def build_element_xml(builder)
22
+ builder.create_and_add_element(name,
23
+ prefix: namespace_prefix,
24
+ attributes: build_attributes(self)) do |xml|
25
+ children.each { |child| child.build_xml(xml) }
26
+ end
27
+ end
28
+
29
+ def attribute_value_for_build(attr)
30
+ attr.raw_value
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1071,6 +1071,30 @@ module Lutaml
1071
1071
  result
1072
1072
  end
1073
1073
 
1074
+ # lutaml-model#765: plain element rules grouped by target attribute
1075
+ # name, for spelling-tolerance mapping (several element names feeding
1076
+ # one attribute). Cached per register so the parse hot path only pays
1077
+ # a hash lookup. Grouping uses rule-level properties only — the
1078
+ # transform applies the collection-attribute filter.
1079
+ #
1080
+ # @return [Hash{Symbol, String => Array<MappingRule>}]
1081
+ def plain_element_rules_by_attr(register_id = nil)
1082
+ reg_key = register_id || :default
1083
+ @plain_element_rules_by_attr ||= {}
1084
+ @plain_element_rules_by_attr[reg_key] ||= begin
1085
+ grouped = {}
1086
+ mappings(reg_key).each do |r|
1087
+ next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
1088
+ r.cdata || r.has_custom_method_for_deserialization? ||
1089
+ (r.transform.is_a?(Hash) && !r.transform.empty?) ||
1090
+ r.transform.is_a?(Class)
1091
+
1092
+ (grouped[r.to] ||= []) << r
1093
+ end
1094
+ grouped
1095
+ end
1096
+ end
1097
+
1074
1098
  def importable_mappings
1075
1099
  @importable_mappings ||= []
1076
1100
  end
@@ -258,6 +258,18 @@ module Lutaml
258
258
  end
259
259
  end
260
260
 
261
+ # lutaml-model#765: several plain element rules may target one
262
+ # collection attribute (spelling tolerance, e.g. editorial-group vs
263
+ # editorialgroup). The first rule of such a group performs matching
264
+ # widened to every spelling so content merges in document order;
265
+ # the remaining rules are no-ops so their assignment cannot
266
+ # silently overwrite the merged value.
267
+ # Grouping is cached on the mapping DSL (per register); only the
268
+ # collection filter runs here.
269
+ grouped_plain_rules = xml_mapping.plain_element_rules_by_attr(
270
+ effective_register,
271
+ )
272
+
261
273
  mappings.each do |rule|
262
274
  # Performance: Cache rule properties accessed multiple times
263
275
  rule.name
@@ -268,6 +280,11 @@ module Lutaml
268
280
  attr = attribute_for_rule(rule)
269
281
  next if attr&.derived?
270
282
 
283
+ if (group = grouped_plain_rules[rule_to]) &&
284
+ group.size > 1 && attr&.collection? && !group.first.equal?(rule)
285
+ next
286
+ end
287
+
271
288
  raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
272
289
  rule, attr
273
290
  )
@@ -285,6 +302,26 @@ module Lutaml
285
302
  doc.root.inner_xml
286
303
  elsif rule.content_mapping?
287
304
  rule.cdata ? doc.cdata : doc.text
305
+ elsif (group = grouped_plain_rules[rule_to]) &&
306
+ group.size > 1 && attr&.collection?
307
+ # First rule of a #765 group: match every spelling of
308
+ # the group so the children merge in document order.
309
+ if child_names_set &&
310
+ group.drop(1).none? do |s|
311
+ child_matches_rule?(s, child_names_set,
312
+ default_namespace)
313
+ end && !child_matches_rule?(rule, child_names_set,
314
+ default_namespace)
315
+ ::Lutaml::Model::UninitializedClass.instance
316
+ else
317
+ extra_names = group.drop(1).flat_map do |s|
318
+ resolve_rule_names_with_type(s, attr, new_opts,
319
+ effective_register,
320
+ attr&.type(effective_register))
321
+ end
322
+ value_for_rule(session, rule, new_opts, attr,
323
+ extra_names)
324
+ end
288
325
  elsif child_names_set && !rule.attribute? &&
289
326
  !child_matches_rule?(rule, child_names_set,
290
327
  default_namespace)
@@ -551,7 +588,8 @@ _effective_register)
551
588
  nil
552
589
  end
553
590
 
554
- def value_for_rule(session, rule, options, cached_attr = nil)
591
+ def value_for_rule(session, rule, options, cached_attr = nil,
592
+ extra_rule_names = nil)
555
593
  doc = session.doc
556
594
  instance = session.instance
557
595
  instance_is_serialize = session.instance_is_serialize
@@ -576,6 +614,12 @@ _effective_register)
576
614
  resolve_rule_names_with_type(rule, attr, options,
577
615
  effective_register, attr_type)
578
616
  end
617
+ # lutaml-model#765: widened matching for the first rule of a
618
+ # shared-attribute group — children of sibling spellings must be
619
+ # selected too, in document order. Deduped: sibling resolution can
620
+ # yield the same spelling twice, and the child index would then
621
+ # return the same children again.
622
+ rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
579
623
 
580
624
  return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
581
625
 
@@ -643,6 +687,13 @@ _effective_register)
643
687
  children = nil # fall through to select for alias matching
644
688
  else
645
689
  children = indexed.flatten(1)
690
+ # #765 widened matching: rule_names spans several spellings, so
691
+ # the flatten above yields rule-name order; restore document
692
+ # order for the merged group.
693
+ if extra_rule_names
694
+ position = element_children.each_with_index.to_h
695
+ children = children.sort_by { |c| position[c] }
696
+ end
646
697
  end
647
698
  else
648
699
  children = nil