lutaml-model 0.8.24 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8aca94b467fe681429ee9863355555d3aa003d9deda8543a74fd1424087bcc8
4
- data.tar.gz: 02db8dceef19fc7ef7abd3a54f0936e00ef162a1ee6477191a8b1d3477ba7860
3
+ metadata.gz: b364f3d15a4c9fc529d85154e1e4789997432f557be698bfef559e3dc1139163
4
+ data.tar.gz: 3fe6af19792fc59b5800d0078e34171dd9b5d4c4009922e43a8eb0050ad8308c
5
5
  SHA512:
6
- metadata.gz: 4da87513ed839ba070c32b28de27411ffe12ccc92f879a0dbe433aad85ea3a32a76759b36112958a131b09a747b1618c0ff8a95e92238aca40f45b736dd46950
7
- data.tar.gz: ff488be2b79ef92c3017d63ee0a9bf4ac3b183549be5b17a59efcb42ea31ae00cacb2b63995f9b69fbe3628af9bd65f30dfedb2fb5d2722fe15189c7e964a93b
6
+ metadata.gz: 7b3ebf674d7f7d306caea42542b54250dcd7ebbffc23186e2e8aa2b932d040e7dcdf4f74f41b1a9b66f089661eacd3eb8cf0676c8728b471f14e5c403175f2d4
7
+ data.tar.gz: e38d81dc78936d9e24d0f389134bca507c7a77d338196663ef10d1986bc3165da3ae7c88043ef0fe5c04189aace3f846034ed5a03f3463d7dfbf48267ab42fa6
data/Gemfile CHANGED
@@ -19,7 +19,6 @@ gem "benchmark-ips"
19
19
  gem "bigdecimal"
20
20
  gem "canon" # , path: "../canon"
21
21
  gem "json-ld"
22
- gem "leptris"
23
22
  gem "liquid", "~> 5"
24
23
  gem "multi_json"
25
24
  gem "nokogiri"
@@ -27,10 +26,13 @@ gem "oj"
27
26
  gem "openssl", "~> 3.0"
28
27
  gem "ox"
29
28
  gem "rake"
30
- # yeptris ships prebuilt platform gems for linux/darwin only; a source
31
- # build is not wired for windows runners.
32
29
  gem "rdf-turtle"
33
30
  gem "rexml"
31
+ # Native engines stay development-only: every adapter detects and
32
+ # degrades gracefully at runtime (safe requires + prefer-if-available).
33
+ # yeptris ships no Windows prebuilt platform gem.
34
+ gem "leptris"
35
+ gem "teptris"
34
36
  gem "yeptris" unless Gem.win_platform?
35
37
  # TODO: revert rng branch to main when lutaml/rng#32 is merged
36
38
  gem "rng", git: "https://github.com/lutaml/rng", branch: "main"
@@ -39,8 +41,6 @@ gem "rubocop"
39
41
  gem "rubocop-performance", require: false
40
42
  gem "rubocop-rake", require: false
41
43
  gem "rubocop-rspec", require: false
42
- # teptris ships prebuilt gems for every platform (incl. mingw-ucrt)
43
- gem "teptris"
44
44
  gem "tomlib"
45
45
  gem "toml-rb"
46
46
 
@@ -169,6 +169,9 @@ require "lutaml/xml/parsed_namespace_declaration"
169
169
  require "lutaml/xml/parsed_namespace_set"
170
170
  require "lutaml/xml/plan_compiler"
171
171
  require "lutaml/xml/plan_hydrator"
172
+ require "lutaml/xml/plan_order"
173
+ require "lutaml/xml/plan_serializer"
174
+ require "lutaml/xml/plan_walk"
172
175
  require "lutaml/xml/polymorphic_value_handler"
173
176
  require "lutaml/xml/qualified_inheritance_strategy"
174
177
  require "lutaml/xml/rexml"
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yeptris"
4
3
  require_relative "standard_adapter"
5
4
 
6
5
  module Lutaml
@@ -13,6 +12,7 @@ module Lutaml
13
12
  # load-only), so everything else inherits from the standard adapter.
14
13
  class YeptrisAdapter < StandardAdapter
15
14
  def self.parse(json, _options = {})
15
+ require "yeptris"
16
16
  ::Yeptris::JSON.load(json)
17
17
  end
18
18
  end
@@ -183,6 +183,22 @@ module Lutaml
183
183
  options.fetch(key, default)
184
184
  end
185
185
 
186
+ # Hot rule-shape reads on the serialization path (form/raw/cdata
187
+ # dominate method_missing traffic — one serialization of the
188
+ # 200-item probe made 4,800 misses). Real methods keep the hot
189
+ # path off method_missing; same source (options), same defaults.
190
+ def form
191
+ options[:form]
192
+ end
193
+
194
+ def raw
195
+ options[:raw]
196
+ end
197
+
198
+ def cdata
199
+ options[:cdata]
200
+ end
201
+
186
202
  # Handle method calls for accessing options dynamically
187
203
  #
188
204
  # This allows options to be accessed as methods (e.g., rule.cdata, rule.raw, rule.mixed_content)
@@ -81,6 +81,29 @@ module Lutaml
81
81
  # No-op by default; XML overrides via prepend
82
82
  end
83
83
 
84
+ # Serialize-side plan fast path: same compiled plan, direct
85
+ # leptris construction. Serialize-shaped plans only — custom
86
+ # methods, polymorphism, unions, and spellings keep the
87
+ # interpretive serializer.
88
+ def xml_plan_fast_serialize(instance, options)
89
+ return nil unless defined?(::Leptris::XML::Document)
90
+ return nil if options.key?(:only) || options.key?(:except) ||
91
+ options.key?(:mappings) || options.key?(:adapter) ||
92
+ options.key?(:_adapter_override) ||
93
+ options.key?(:indent) || options.key?(:xml_declaration) ||
94
+ options.key?(:declaration) || options.key?(:doctype)
95
+
96
+ adapter_name = Lutaml::Model::Config.adapter_for(:xml)
97
+ adapter_name &&= adapter_name.name
98
+ return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
99
+
100
+ register = Lutaml::Model::Config.default_register
101
+ plan = Lutaml::Xml::PlanCompiler.compile(self, register)
102
+ return nil unless plan && Lutaml::Xml::PlanSerializer.serializable?(plan)
103
+
104
+ Lutaml::Xml::PlanSerializer.call(instance, plan)
105
+ end
106
+
84
107
  # Whole-document native materialization (Phase 5): compile the
85
108
  # mapping into a Leptris descriptor plan and hydrate from one
86
109
  # plan walk. Only when the resolved XML adapter is leptris-
@@ -106,7 +129,8 @@ module Lutaml
106
129
  return nil unless root.name == plan[:tree][:name]
107
130
 
108
131
  Lutaml::Xml::PlanHydrator.call(self, plan,
109
- plan[:descriptor].walk(root))
132
+ plan[:descriptor].walk(root),
133
+ node: root)
110
134
  rescue ::Leptris::XML::ParseError => e
111
135
  raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
112
136
  end
@@ -240,6 +264,10 @@ module Lutaml
240
264
  Instrumentation.instrument(:to, model: name, format: format) do
241
265
  adapter_override = options.delete(:adapter)
242
266
  options[:_adapter_override] = true if adapter_override
267
+ if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
268
+ fast = xml_plan_fast_serialize(instance, options)
269
+ return fast if fast
270
+ end
243
271
  value = public_send(:"as_#{format}", instance, options)
244
272
  adapter = resolve_adapter(format, adapter_override)
245
273
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.24"
5
+ VERSION = "0.8.28"
6
6
  end
7
7
  end
@@ -281,12 +281,18 @@ module Lutaml
281
281
 
282
282
  attr_reader :register
283
283
 
284
- # Resolve the correct register for a child mapper class
284
+ # Resolve the correct register for a child mapper class.
285
+ # Hoisted per (mapper class) on the planner: resolve_for_child is
286
+ # globally cached but each call still allocates its key — the
287
+ # serialization hot path resolves the same handful of child
288
+ # classes per element.
285
289
  #
286
290
  # @param mapper_class [Class] The child's mapper class
287
291
  # @return [Symbol] The resolved register ID
288
292
  def register_for(mapper_class)
289
- Lutaml::Model::Register.resolve_for_child(mapper_class, @register)
293
+ @register_for ||= {}
294
+ @register_for[mapper_class] ||=
295
+ Lutaml::Model::Register.resolve_for_child(mapper_class, @register)
290
296
  end
291
297
 
292
298
  # Build individual child plans for collection items
@@ -23,6 +23,9 @@ module Lutaml
23
23
  module Xml
24
24
  autoload :PlanCompiler, "#{__dir__}/plan_compiler"
25
25
  autoload :PlanHydrator, "#{__dir__}/plan_hydrator"
26
+ autoload :PlanOrder, "#{__dir__}/plan_order"
27
+ autoload :PlanSerializer, "#{__dir__}/plan_serializer"
28
+ autoload :PlanWalk, "#{__dir__}/plan_walk"
26
29
  # Error module for XML-specific errors
27
30
  module Error
28
31
  autoload :XmlError, "#{__dir__}/error/xml_error"
@@ -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
@@ -9,13 +9,15 @@ module Lutaml
9
9
  # walk+hydrate 8.7x faster, 82% fewer allocations than the
10
10
  # interpretive path, hydration-equal output.
11
11
  #
12
- # A model compiles only when EVERY rule is plan-shaped; anything
13
- # richer keeps the interpretive path (all-or-nothing per model):
14
- # - map_attribute / map_element rows only
15
- # - element rows: Value-scalar types or nested Serializables
16
- # - no content/raw mappings, custom methods, delegates,
17
- # polymorphism, transforms, multiple spellings, namespaces,
18
- # ordered mappings, hash_mappings, root_mappings
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
19
21
  # - attributes not derived/union/polymorphic
20
22
  #
21
23
  # The fast path is opt-in (Config.xml_plan_fast_path) while the
@@ -52,48 +54,133 @@ module Lutaml
52
54
  rows = []
53
55
  attr_rows = [] # [[rule, attr]] hydration metadata
54
56
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
55
- compiled = [] # [rule, attr, kind] in children order
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
+
56
70
  mapping.mappings(register).each do |rule|
57
- attr = model_class.attributes(register)[rule.to]
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
58
81
  return nil if attr.nil?
59
- return nil if attr.derived? || attr.union? || attr.polymorphic?
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
60
96
 
61
97
  if rule.attribute?
62
98
  return nil unless scalar_type?(attr, register)
63
99
 
64
100
  attr_rows << [rule, attr]
65
101
  plan_attrs << { name: rule.name.to_s }
66
- else
67
- return nil unless plan_element_row?(rule)
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?
68
120
 
121
+ needs_nodes = true
122
+ compiled << [rule, attr, :polymorphic, nil, delegate_target]
123
+ rows << { name: rule.name.to_s, kind: :raw }
124
+ else
69
125
  type = attr.type(register)
70
126
  if serializable_type?(type)
71
127
  child = compile(type, register)
72
128
  return nil unless child
73
129
 
74
- compiled << [rule, attr, :nested]
75
- rows << { name: rule.name.to_s, kind: :nested,
76
- plan: child[:tree] }
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
77
157
  else
78
158
  return nil unless scalar_type?(attr, register)
79
159
 
80
- kind = attr.collection? ? :collection : :scalar
81
- # Collection row values carry neither name nor type_tag
82
- # echo (leptris 1.9.174): hydration can only attribute
83
- # them unambiguously with a single collection row per
84
- # element.
85
- if kind == :collection && rows.count { |r| r[:kind] == :collection } >= 1
86
- return nil
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)
87
171
  end
88
-
89
- compiled << [rule, attr, kind]
90
- rows << { name: rule.name.to_s, kind: kind }
91
172
  end
92
173
  end
93
174
  end
94
175
 
95
- tree = { name: mapping.root_element.to_s, attributes: plan_attrs,
96
- children: rows }
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?
97
184
  begin
98
185
  # Lazy: the Opal boot loads this file, and leptris is a
99
186
  # native gem there — the require only belongs on the
@@ -105,23 +192,50 @@ module Lutaml
105
192
  end
106
193
 
107
194
  { descriptor: descriptor, tree: tree, rows: compiled,
108
- attr_rows: attr_rows, mapping: mapping }
195
+ attr_rows: attr_rows, mapping: mapping,
196
+ ordered: mapping.ordered? || mapping.mixed_content?,
197
+ needs_nodes: needs_nodes,
198
+ collection_defaults: collection_defaults }
109
199
  end
110
200
 
111
201
  def compilable_mapping?(mapping)
112
- !mapping.ordered? && mapping.root_element &&
202
+ mapping.root_element &&
113
203
  !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
114
204
  end
115
205
 
116
- def plan_element_row?(rule)
117
- !rule.raw_mapping? && !rule.content_mapping? &&
118
- !rule.delegate && !rule.multiple_mappings? &&
119
- !rule.namespace_set? && !rule.cdata &&
120
- !rule.mixed_content && !rule.as_list && !rule.delimiter &&
121
- !rule.has_custom_method_for_deserialization? &&
122
- !rule.polymorphic_mapping? &&
123
- !(rule.transform.is_a?(Hash) && !rule.transform.empty?) &&
124
- !rule.transform.is_a?(Class)
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
125
239
  end
126
240
 
127
241
  def scalar_type?(attr, register)