lutaml-model 0.8.23 → 0.8.28

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 (73) 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/_pages/configuration.adoc +11 -7
  8. data/docs/_pages/serialization_adapters.adoc +14 -5
  9. data/lib/compat/opal/lutaml_model_boot.rb +5 -0
  10. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  11. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  12. data/lib/lutaml/json/adapter.rb +1 -0
  13. data/lib/lutaml/json/format.rb +1 -1
  14. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  15. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  16. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  17. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  18. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  19. data/lib/lutaml/key_value/transform.rb +82 -0
  20. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  21. data/lib/lutaml/key_value/transformation.rb +25 -1
  22. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  23. data/lib/lutaml/model/attribute.rb +31 -6
  24. data/lib/lutaml/model/compiled_rule.rb +16 -0
  25. data/lib/lutaml/model/configuration.rb +5 -0
  26. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  27. data/lib/lutaml/model/serialize/format_conversion.rb +65 -0
  28. data/lib/lutaml/model/toml.rb +10 -6
  29. data/lib/lutaml/model/type/date_time.rb +2 -2
  30. data/lib/lutaml/model/type/value.rb +24 -2
  31. data/lib/lutaml/model/utils.rb +6 -5
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  34. data/lib/lutaml/toml/adapter.rb +1 -0
  35. data/lib/lutaml/toml/format.rb +1 -1
  36. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  37. data/lib/lutaml/xml/adapter_element.rb +111 -3
  38. data/lib/lutaml/xml/builder/base.rb +3 -1
  39. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  40. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  41. data/lib/lutaml/xml/builder.rb +1 -0
  42. data/lib/lutaml/xml/declaration_planner.rb +8 -2
  43. data/lib/lutaml/xml/format.rb +7 -1
  44. data/lib/lutaml/xml/leptris/element.rb +34 -0
  45. data/lib/lutaml/xml/namespace_collector.rb +4 -3
  46. data/lib/lutaml/xml/plan_compiler.rb +253 -0
  47. data/lib/lutaml/xml/plan_hydrator.rb +341 -0
  48. data/lib/lutaml/xml/plan_order.rb +50 -0
  49. data/lib/lutaml/xml/plan_serializer.rb +241 -0
  50. data/lib/lutaml/xml/plan_walk.rb +30 -0
  51. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  52. data/lib/lutaml/xml/xml_element.rb +6 -2
  53. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +46 -0
  54. data/lib/lutaml/yaml/adapter.rb +5 -0
  55. data/lib/lutaml/yaml/format.rb +1 -1
  56. data/lib/tasks/performance.rake +5 -0
  57. data/lib/tasks/performance_comparator.rb +19 -3
  58. data/lib/tasks/performance_probe.rake +33 -0
  59. data/lib/tasks/performance_probe.rb +106 -0
  60. data/lib/tasks/performance_probe_plan_walk.rb +58 -0
  61. data/lib/tasks/performance_probe_xml.rb +54 -0
  62. data/lutaml-model.gemspec +1 -1
  63. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  64. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  65. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  66. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  67. data/spec/lutaml/model/toml_spec.rb +36 -1
  68. data/spec/lutaml/xml/plan_fast_path_spec.rb +547 -0
  69. data/spec/lutaml/xml/plan_walk_spec.rb +102 -0
  70. data/spec/lutaml/xml/xml_spec.rb +13 -0
  71. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  72. data/spec/spec_helper.rb +7 -1
  73. metadata +28 -4
@@ -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
@@ -566,9 +566,10 @@ module Lutaml
566
566
  # Resolve type using child model's register context
567
567
  mapper_class = options[:mapper_class]
568
568
  child_register = if mapper_class
569
- Lutaml::Model::Register.resolve_for_child(
570
- mapper_class, @register
571
- )
569
+ (@register_for ||= {})[mapper_class] ||=
570
+ Lutaml::Model::Register.resolve_for_child(
571
+ mapper_class, @register
572
+ )
572
573
  else
573
574
  @register
574
575
  end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Phase 5 slice: compile a model's XML mapping into a
6
+ # Leptris::XML::Descriptor plan and materialize whole documents in
7
+ # one native pass (leptris_plan_walk) — no moxml wrapper tree, no
8
+ # per-element Ruby dispatch. Measured on the 200-item probe:
9
+ # walk+hydrate 8.7x faster, 82% fewer allocations than the
10
+ # interpretive path, hydration-equal output.
11
+ #
12
+ # A model compiles when EVERY rule is plan-shaped; richer rows
13
+ # defer their subtree verbatim and interpret post-walk rather
14
+ # than opting the whole model out:
15
+ # - map_attribute / map_element / content / raw rows
16
+ # - Value-scalar types or nested Serializables
17
+ # - custom methods, polymorphism, unions, and ordered/mixed
18
+ # children capture :raw and interpret post-walk
19
+ # - ordered/mixed root mappings compile; the entry points
20
+ # reconstruct element_order from the node surface
21
+ # - attributes not derived/union/polymorphic
22
+ #
23
+ # The fast path is opt-in (Config.xml_plan_fast_path) while the
24
+ # full semantics audit (parent links, consolidation, ordering
25
+ # metadata) completes.
26
+ module PlanCompiler
27
+ # Isolated holder: suites freeze model classes; a cache on a
28
+ # frozen constant would be immutable (TypeProbeCache precedent).
29
+ PLAN_CACHE = ::Class.new do
30
+ class << self
31
+ def cache
32
+ @cache ||= {}
33
+ end
34
+ end
35
+ end.cache
36
+
37
+ class << self
38
+ def compile(model_class, register)
39
+ key = [model_class, register]
40
+ return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
41
+
42
+ PLAN_CACHE[key] = build(model_class, register)
43
+ end
44
+
45
+ private
46
+
47
+ def build(model_class, register)
48
+ return nil unless model_class.is_a?(Class) &&
49
+ model_class.include?(::Lutaml::Model::Serialize)
50
+
51
+ mapping = model_class.mappings_for(:xml, register)
52
+ return nil unless compilable_mapping?(mapping)
53
+
54
+ rows = []
55
+ attr_rows = [] # [[rule, attr]] hydration metadata
56
+ plan_attrs = [] # [{name:, kind:}] rows for the engine plan
57
+ compiled = [] # [rule, attr, kind, spelling, delegate_target]
58
+ cdata = false
59
+ mixed_content = false
60
+ needs_nodes = false
61
+ collection_defaults = [] # collection attrs with element rows
62
+ tag = 100 # type_tag echo space for callback-routed rows
63
+ model_ns = plan_namespace(model_class, mapping, register)
64
+
65
+ # Collection rows are NATIVE since leptris 1.9.178 —
66
+ # collection values echo name and type_tag (the #220 gap is
67
+ # closed), so any number routes without callbacks.
68
+ true
69
+
70
+ mapping.mappings(register).each do |rule|
71
+ delegate_target = nil
72
+ attr = if rule.delegate
73
+ delegate_target = model_class.attributes(register)[rule.delegate]
74
+ t = delegate_target&.type(register)
75
+ if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
76
+ t.attributes(register)[rule.to]
77
+ end
78
+ else
79
+ model_class.attributes(register)[rule.to]
80
+ end
81
+ return nil if attr.nil?
82
+ return nil if attr.derived?
83
+
84
+ # Interpretive hydration materializes every mapped
85
+ # collection, present or not; the fast path mirrors with
86
+ # constructor-time empty arrays.
87
+ unless rule.attribute? || !attr.collection?
88
+ collection_defaults << attr.name.to_sym
89
+ end
90
+
91
+ # Fragment deferrals lose ancestor namespace context —
92
+ # ns-qualified models keep those rules interpretive.
93
+ fragment_needed = rule.has_custom_method_for_deserialization? ||
94
+ rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
95
+ return nil if fragment_needed && model_ns
96
+
97
+ if rule.attribute?
98
+ return nil unless scalar_type?(attr, register)
99
+
100
+ attr_rows << [rule, attr]
101
+ plan_attrs << { name: rule.name.to_s }
102
+ elsif rule.content_mapping?
103
+ return nil if content_rows(rows) >= 1
104
+
105
+ mixed_content = true
106
+ compiled << [rule, attr,
107
+ attr.collection? ? :content : :content_deferred,
108
+ nil, delegate_target]
109
+ rows << { name: "__content__#{compiled.size}", kind: :content }
110
+ elsif rule.raw_mapping? || rule.raw == :element
111
+ compiled << [rule, attr, :raw, nil, delegate_target]
112
+ rows << { name: rule.name.to_s, kind: :raw }
113
+ elsif rule.has_custom_method_for_deserialization?
114
+ needs_nodes = true
115
+ compiled << [rule, attr, :custom_method, nil, delegate_target]
116
+ rows << { name: rule.name.to_s, kind: :raw }
117
+ elsif rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
118
+ type = attr.type(register)
119
+ return nil unless serializable_type?(type) || attr.union?
120
+
121
+ needs_nodes = true
122
+ compiled << [rule, attr, :polymorphic, nil, delegate_target]
123
+ rows << { name: rule.name.to_s, kind: :raw }
124
+ else
125
+ type = attr.type(register)
126
+ if serializable_type?(type)
127
+ child = compile(type, register)
128
+ return nil unless child
129
+
130
+ if child[:ordered]
131
+ # Ordered/mixed children need element_order on their
132
+ # instances; the walk hands back plan values, not
133
+ # source nodes, so the subtree defers interpretively
134
+ # (the fragment parse runs the full machinery,
135
+ # order included).
136
+ return nil if model_ns
137
+
138
+ needs_nodes = true
139
+ compiled << [rule, attr, :ordered_deferred, nil,
140
+ delegate_target]
141
+ rows << { name: rule.name.to_s, kind: :raw }
142
+ else
143
+ compiled << [rule, attr, :nested, nil, delegate_target]
144
+ needs_nodes ||= child[:needs_nodes]
145
+ nested_row = { name: rule.name.to_s, kind: :nested,
146
+ plan: child[:tree] }
147
+ nested_row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
148
+ rows << nested_row
149
+ end
150
+ elsif rule.multiple_mappings?
151
+ rule.name.each do |spelling|
152
+ compiled << [rule, attr, :spelling, spelling.to_s,
153
+ delegate_target]
154
+ rows << { name: spelling.to_s, kind: :callback,
155
+ type_tag: (tag += 1) }
156
+ end
157
+ else
158
+ return nil unless scalar_type?(attr, register)
159
+
160
+ row = { name: rule.name.to_s }
161
+ row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
162
+
163
+ if attr.collection?
164
+ compiled << [rule, attr, :collection_native, nil,
165
+ delegate_target]
166
+ rows << row.merge(kind: :collection)
167
+ else
168
+ cdata ||= rule.cdata
169
+ compiled << [rule, attr, :scalar, nil, delegate_target]
170
+ rows << row.merge(kind: :scalar)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ flags = []
177
+ flags << :cdata if cdata
178
+ flags << :mixed_content if mixed_content
179
+ flags << :ns_lenient if model_ns
180
+ tree = { name: mapping.root_element.to_s,
181
+ attributes: plan_attrs, children: rows }
182
+ tree[:ns] = model_ns if model_ns
183
+ tree[:flags] = flags unless flags.empty?
184
+ begin
185
+ # Lazy: the Opal boot loads this file, and leptris is a
186
+ # native gem there — the require only belongs on the
187
+ # engines-enabled path.
188
+ require "leptris/xml/descriptor"
189
+ descriptor = ::Leptris::XML::Descriptor.build(**tree)
190
+ rescue StandardError
191
+ return nil
192
+ end
193
+
194
+ { descriptor: descriptor, tree: tree, rows: compiled,
195
+ attr_rows: attr_rows, mapping: mapping,
196
+ ordered: mapping.ordered? || mapping.mixed_content?,
197
+ needs_nodes: needs_nodes,
198
+ collection_defaults: collection_defaults }
199
+ end
200
+
201
+ def compilable_mapping?(mapping)
202
+ mapping.root_element &&
203
+ !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
204
+ end
205
+
206
+ # Attribute for a rule — delegate rules resolve against their
207
+ # target model's attributes.
208
+ def attr_of(model_class, rule, register)
209
+ attr = model_class.attributes(register)[rule.to]
210
+ return attr if attr || !rule.delegate
211
+
212
+ target = model_class.attributes(register)[rule.delegate]
213
+ t = target&.type(register)
214
+ if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
215
+ t.attributes(register)[rule.to]
216
+ end
217
+ end
218
+
219
+ # Rule-level namespace → ChildPlan ns form (leptris 1.9.178):
220
+ # the child binds by local name under the rule's URI with any
221
+ # prefix. Blank-namespace rules (xmlns="") match :none.
222
+ def child_ns(rule, _model_ns)
223
+ uri = rule.namespace
224
+ return { exact: uri.to_s } if uri && !uri.to_s.empty?
225
+
226
+ :none
227
+ end
228
+
229
+ def content_rows(rows)
230
+ rows.count { |r| r[:kind] == :content }
231
+ end
232
+
233
+ # Model-level namespace: exact URI match with lenient prefixes
234
+ # — children bind by local name under any prefix the document
235
+ # bound to the URI (#754 adoption semantics on the engine).
236
+ def plan_namespace(_model_class, mapping, _register)
237
+ ns_class = mapping.namespace_class if mapping.respond_to?(:namespace_class)
238
+ ns_class&.uri ? { exact: ns_class.uri.to_s } : nil
239
+ end
240
+
241
+ def scalar_type?(attr, register)
242
+ type = attr.type(register)
243
+ type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
244
+ !attr.custom_collection?
245
+ end
246
+
247
+ def serializable_type?(type)
248
+ type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Hydrates model instances from a Descriptor#walk PlanValue tree,
6
+ # keyed by each value's producing row name (never position — rows
7
+ # for missing elements are simply absent). Native rows build
8
+ # constructor kwargs; deferred rows (custom methods, polymorphism,
9
+ # unions) capture their subtree verbatim and interpret it
10
+ # post-walk — the fragment parse runs the existing interpretive
11
+ # machinery on just that island. Delegate rules hydrate
12
+ # post-instance onto their target object. Collection rows route
13
+ # natively when a single one exists, else through callback rows
14
+ # (their values echo name and type_tag; native collection values
15
+ # echo neither — leptris-ruby#220).
16
+ module PlanHydrator
17
+ class << self
18
+ # plan: the compiler's entry for model_class
19
+ # value: the walk root PlanValue (element)
20
+ # node: the parsed source element (leptris); ordered/mixed
21
+ # plans rebuild element_order from it and ordered children
22
+ # hydrate natively against their own nodes
23
+ def call(model_class, plan, value, parent: nil, node: nil)
24
+ buckets = node && plan[:needs_nodes] ? element_buckets(node) : nil
25
+ attr_kwargs = attributes_kwargs(plan, value)
26
+ child_kwargs, children, delegates =
27
+ children_kwargs(model_class, plan, value, buckets)
28
+ plan[:collection_defaults].each do |name|
29
+ next if attr_kwargs.key?(name) || child_kwargs.key?(name)
30
+
31
+ child_kwargs[name] = []
32
+ end
33
+ instance = model_class.new(**attr_kwargs, **child_kwargs)
34
+ instance.lutaml_parent = parent if parent
35
+ instance.lutaml_root ||= parent&.lutaml_root || parent
36
+ instance.element_order = PlanOrder.build(node) if node && plan[:ordered]
37
+ children.each do |child|
38
+ child.lutaml_parent = instance
39
+ child.lutaml_root ||= instance.lutaml_root || instance
40
+ end
41
+ interpret_deferred(model_class, plan, value, instance, node, buckets)
42
+ route_delegates(delegates, instance)
43
+ instance
44
+ end
45
+
46
+ private
47
+
48
+ def register
49
+ Lutaml::Model::Config.default_register
50
+ end
51
+
52
+ def attributes_kwargs(plan, value)
53
+ kwargs = {}
54
+ plan[:attr_rows].each do |rule, attr|
55
+ v = value.attribute(rule.name.to_s)
56
+ next if v.nil?
57
+
58
+ v = v.split(rule.delimiter) if rule.delimiter
59
+ if rule.as_list && rule.as_list[:import]
60
+ v = rule.as_list[:import].call(v)
61
+ end
62
+ kwargs[attr.name.to_sym] = v
63
+ end
64
+ kwargs
65
+ end
66
+
67
+ # Returns [kwargs, hydrated_child_instances, delegate_values]
68
+ # — child instances come back so the caller can decorate
69
+ # parent/root links once the parent exists; delegate values
70
+ # wait for the instance (their target object must exist).
71
+ def children_kwargs(_model_class, plan, value, buckets = nil)
72
+ grouped = group_children(value)
73
+ buckets = nil unless buckets && plan[:needs_nodes]
74
+ kwargs = {}
75
+ children = []
76
+ delegates = []
77
+ spellings = Hash.new { |h, k| h[k] = [] }
78
+ plan[:rows].each do |rule, attr, kind, spelling, delegate|
79
+ case kind
80
+ when :scalar
81
+ # Raw passthrough: the model constructor is the single
82
+ # cast authority — pre-casting here doubled every cast.
83
+ # Class transforms still apply before assignment.
84
+ v = grouped.dig(rule.name.to_s, 0)&.string_value
85
+ unless v.nil?
86
+ v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
87
+ assign(kwargs, delegates, delegate, rule, attr, v)
88
+ end
89
+ when :raw, :custom_method, :polymorphic, :content_deferred
90
+ # interpreted post-instance (interpret_deferred)
91
+ when :content
92
+ assign(kwargs, delegates, delegate, rule, attr,
93
+ content_runs(value))
94
+ when :ordered_deferred
95
+ # With a source node the child hydrates natively: one
96
+ # walk against its own node + element_order from the
97
+ # node's children. Without one (direct PlanHydrator
98
+ # use), the subtree fragment-parses interpretively.
99
+ if buckets
100
+ child_type = attr.type(register)
101
+ child_plan = PlanCompiler.compile(child_type, register)
102
+ items = buckets.fetch(rule.name.to_s, []).map do |n|
103
+ call(child_type, child_plan,
104
+ child_plan[:descriptor].walk(n), node: n)
105
+ end
106
+ unless items.empty?
107
+ children.concat(items)
108
+ assign(kwargs, delegates, delegate, rule, attr,
109
+ attr.collection? ? items : items.first)
110
+ end
111
+ end
112
+ when :collection_cb
113
+ values = grouped[rule.name.to_s].to_a.map(&:string_value)
114
+ if rule.transform.is_a?(Class)
115
+ values = values.map { |v| rule.transform_value(attr, v, :from, :xml) }
116
+ end
117
+ unless values.empty?
118
+ assign(kwargs, delegates, delegate, rule, attr, values)
119
+ end
120
+ when :collection_native
121
+ values = native_collection(value, rule.name.to_s)
122
+ unless values.nil?
123
+ assign(kwargs, delegates, delegate, rule, attr, values)
124
+ end
125
+ when :spelling
126
+ spellings[[rule, attr]] << grouped[spelling.to_s].to_a
127
+ when :nested
128
+ child_type = attr.type(register)
129
+ child_plan = PlanCompiler.compile(child_type, register)
130
+ cursor = if child_plan[:needs_nodes] && buckets
131
+ buckets.fetch(rule.name.to_s, [])
132
+ end
133
+ items = grouped.fetch(rule.name.to_s, []).each_with_index
134
+ .map do |v, i|
135
+ call(child_type, child_plan, v,
136
+ node: cursor && cursor[i])
137
+ end
138
+ next if items.empty?
139
+
140
+ children.concat(items)
141
+ assign(kwargs, delegates, delegate, rule, attr,
142
+ attr.collection? ? items : items.first)
143
+ end
144
+ end
145
+ unless spellings.empty?
146
+ spellings.each do |(rule, attr), groups|
147
+ # Interpretive order for shared-attribute groups is
148
+ # spelling-group order (first spelling's matches, then
149
+ # the next's), not interleaved document order.
150
+ values = groups.compact.flatten.map(&:string_value)
151
+ next if values.empty?
152
+
153
+ delegate = delegate_of(plan, rule)
154
+ assign(kwargs, delegates, delegate, rule, attr,
155
+ attr.collection? ? values : values.first)
156
+ end
157
+ end
158
+ [kwargs, children, delegates]
159
+ end
160
+
161
+ # Deferred islands: raw subtrees become wrapper elements via a
162
+ # fragment parse, then the interpretive machinery runs on just
163
+ # that island — custom method invocation with an
164
+ # element-shaped argument, or the polymorphic/union cast.
165
+ # With the source node available the island wraps the ORIGINAL
166
+ # node (no fragment parse — the reparse dominated deferred
167
+ # hydration). Ordered children only land here without a node.
168
+ def interpret_deferred(model_class, plan, value, instance, node = nil,
169
+ buckets = nil)
170
+ plan[:rows].each do |rule, attr, kind, _spelling, _delegate|
171
+ case kind
172
+ when :content_deferred
173
+ runs = content_runs(value)
174
+ next if runs.empty?
175
+
176
+ # Non-collection content attrs hold the joined text
177
+ # (the interpretive path assigns element text, the runs
178
+ # concatenated).
179
+ instance.public_send(:"#{attr.name}=", runs.join)
180
+ when :raw
181
+ raws = raw_strings(value, rule)
182
+ next if raws.empty?
183
+
184
+ instance.public_send(:"#{attr.name}=",
185
+ attr.collection? ? raws : raws.first)
186
+ when :custom_method
187
+ elements = deferred_elements(value, rule, buckets)
188
+ next if elements.empty?
189
+
190
+ args = attr.collection? ? elements : elements.first
191
+ rule.deserialize(instance, args,
192
+ model_class.attributes(register),
193
+ model_class)
194
+ when :ordered_deferred
195
+ next if node # hydrated natively in children_kwargs
196
+
197
+ results = deferred_elements(value, rule, buckets).map do |element|
198
+ attr.cast(element, :xml, register,
199
+ lutaml_parent: instance,
200
+ lutaml_root: instance.lutaml_root || instance)
201
+ end
202
+ next if results.empty?
203
+
204
+ instance.public_send(:"#{attr.name}=",
205
+ attr.collection? ? results : results.first)
206
+ when :polymorphic
207
+ results = deferred_elements(value, rule, buckets).map do |element|
208
+ attr.cast(element, :xml, register,
209
+ polymorphic: rule.polymorphic,
210
+ lutaml_parent: instance,
211
+ lutaml_root: instance.lutaml_root || instance)
212
+ end
213
+ next if results.empty?
214
+
215
+ instance.public_send(:"#{attr.name}=",
216
+ attr.collection? ? results : results.first)
217
+ end
218
+ end
219
+ end
220
+
221
+ # Wrapper elements for deferred rows: the ORIGINAL source
222
+ # nodes bridged through moxml's wrapper cache (wrapper
223
+ # construction only, no parse), falling back to a fragment
224
+ # parse of the captured subtree when no node is available.
225
+ # Both lists are document-ordered for the row's name.
226
+ def deferred_elements(value, rule, buckets)
227
+ nodes = buckets && buckets[rule.name.to_s]
228
+ return nodes.map { |n| bridge_element(n) } if nodes
229
+
230
+ raw_strings(value, rule).map { |raw| fragment_element(raw) }
231
+ end
232
+
233
+ def bridge_element(node)
234
+ Lutaml::Xml::LeptrisElement.new(
235
+ Moxml::Node.wrap(node, bridge_context),
236
+ )
237
+ end
238
+
239
+ def bridge_context
240
+ @bridge_context ||= Moxml::Context.new(:leptris)
241
+ end
242
+
243
+ def route_delegates(delegates, instance)
244
+ delegates.each do |rule, delegate_attr, attr, v|
245
+ target = instance.public_send(rule.delegate)
246
+ unless target
247
+ # the interpretive path instantiates absent delegate
248
+ # targets; mirror it
249
+ target = delegate_attr.type(register).new
250
+ instance.public_send(:"#{rule.delegate}=", target)
251
+ end
252
+ target.public_send(:"#{attr.name}=", v)
253
+ end
254
+ end
255
+
256
+ def delegate_of(plan, rule)
257
+ entry = plan[:rows].find { |r, _, _k, _| r.equal?(rule) }
258
+ entry && entry[4]
259
+ end
260
+
261
+ # Delegate rules hold their values for post-instance routing
262
+ # (the target object must exist first); plain rules land in
263
+ # kwargs directly.
264
+ def assign(kwargs, delegates, delegate, rule, attr, value)
265
+ if delegate
266
+ delegates << [rule, delegate, attr, value]
267
+ else
268
+ kwargs[attr.name.to_sym] = value
269
+ end
270
+ end
271
+
272
+ def content_runs(value)
273
+ out = []
274
+ value.count.times do |i|
275
+ c = value.at(i)
276
+ next unless c.name.nil? && c.kind == :collection
277
+
278
+ out.concat(Array.new(c.count) { |j| c.at(j).string_value })
279
+ end
280
+ out
281
+ end
282
+
283
+ # Native collection rows echo their producing row's name
284
+ # (leptris 1.9.178).
285
+ def native_collection(value, name)
286
+ value.count.times do |i|
287
+ c = value.at(i)
288
+ next unless c.kind == :collection && c.name == name
289
+
290
+ return Array.new(c.count) { |j| c.at(j).string_value }
291
+ end
292
+ nil
293
+ end
294
+
295
+ def raw_strings(value, rule)
296
+ out = []
297
+ value.count.times do |idx|
298
+ child = value.at(idx)
299
+ if child.name == rule.name.to_s && child.kind == :raw
300
+ out << child.string_value
301
+ end
302
+ end
303
+ out
304
+ end
305
+
306
+ # Fragment deferral: parse the captured subtree back into a
307
+ # wrapper element. Ancestor namespace context is absent (the
308
+ # compiler only defers on namespace-free model chains).
309
+ def fragment_element(raw)
310
+ Lutaml::Xml::Adapter::LeptrisAdapter.parse(raw).root
311
+ end
312
+
313
+ def group_children(value)
314
+ grouped = {}
315
+ value.count.times do |i|
316
+ child = value.at(i)
317
+ next if child.name.nil? # content runs, read separately
318
+
319
+ (grouped[child.name] ||= []) << child
320
+ end
321
+ grouped
322
+ end
323
+
324
+ # Element children bucketed by local name, document order
325
+ # preserved — the node-side mirror of group_children for
326
+ # ordered-child hydration. Only built when the plan's subtree
327
+ # needs source nodes; namespace-qualified models never get
328
+ # here (the compiler keeps them interpretive).
329
+ def element_buckets(node)
330
+ buckets = {}
331
+ node.children.each do |child|
332
+ next unless child.is_a?(::Leptris::XML::Element)
333
+
334
+ (buckets[child.name] ||= []) << child
335
+ end
336
+ buckets
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Reconstructs element_order for ordered/mixed models from the
6
+ # leptris node surface in one native pass, mirroring the
7
+ # interpretive AdapterElement#order contract: text runs, CDATA,
8
+ # comments, PIs, and element entries (local name + namespace
9
+ # uri/prefix) in document order, frozen like the interpretive
10
+ # product.
11
+ module PlanOrder
12
+ TEXT_MARKER = "text"
13
+ CDATA_MARKER = "#cdata-section"
14
+ COMMENT_MARKER = "comment"
15
+
16
+ class << self
17
+ def build(node)
18
+ node.children.filter_map { |child| entry_for(child) }
19
+ .each(&:freeze).freeze
20
+ end
21
+
22
+ private
23
+
24
+ def entry_for(child)
25
+ case child
26
+ when ::Leptris::XML::CDATA
27
+ Element.new("Text", CDATA_MARKER,
28
+ text_content: child.content, node_type: :cdata)
29
+ when ::Leptris::XML::Text
30
+ Element.new("Text", TEXT_MARKER,
31
+ text_content: child.text, node_type: :text)
32
+ when ::Leptris::XML::Comment
33
+ Element.new("Comment", COMMENT_MARKER,
34
+ text_content: child.content, node_type: :comment)
35
+ when ::Leptris::XML::ProcessingInstruction
36
+ Element.new("ProcessingInstruction", child.name,
37
+ text_content: child.content.to_s.sub(/\A\s+/, ""),
38
+ node_type: :processing_instruction)
39
+ when ::Leptris::XML::Element
40
+ ns = child.namespace
41
+ Element.new("Element", child.name,
42
+ node_type: :element,
43
+ namespace_uri: ns&.values&.first,
44
+ namespace_prefix: ns&.keys&.first)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end