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.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Public consumer surface for the plan fast path: one engine
6
+ # parse + descriptor walk and direct model hydration, bypassing
7
+ # the wrapper-tree construction that dominates large-document
8
+ # consumer paths. The returned object is the mapped model root;
9
+ # collection attributes remain collections on that model.
10
+ module PlanWalk
11
+ class << self
12
+ # Build the plan, walk the document, and hydrate the mapped
13
+ # root. Returns nil when the model is not plan-compilable.
14
+ def call(model_class, xml, _options = {})
15
+ register = Lutaml::Model::Config.default_register
16
+ plan = PlanCompiler.compile(model_class, register)
17
+ return nil unless plan
18
+
19
+ document = ::Leptris::XML.parse(xml)
20
+ root = document.root
21
+ return nil unless root && root.name == plan[:tree][:name]
22
+
23
+ PlanHydrator.call(model_class, plan,
24
+ plan[:descriptor].walk(root),
25
+ node: root)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yeptris"
4
- require "yeptris/yaml"
5
3
  require_relative "standard_adapter"
6
4
 
7
5
  module Lutaml
@@ -18,7 +16,13 @@ module Lutaml
18
16
  # and aliases always resolve; a tagged value without a core type
19
17
  # materializes as plain data instead of raising DisallowedClass.
20
18
  class YeptrisAdapter < StandardAdapter
19
+ # yeptris is required lazily: the ruby-platform variant installs
20
+ # everywhere but only loads where libyeptris exists (no Windows
21
+ # prebuilts) — a broken install must degrade to detection
22
+ # fallback, never crash loads.
21
23
  def self.parse(yaml, _options = {})
24
+ require "yeptris"
25
+ require "yeptris/yaml"
22
26
  # rubocop:disable-next Naming/VariableNumber -- upstream schema literal
23
27
  Yeptris::YAML.load(yaml, schema: :compat_11)
24
28
  end
@@ -32,6 +36,8 @@ module Lutaml
32
36
  @attributes
33
37
  end
34
38
 
39
+ require "yeptris"
40
+ require "yeptris/yaml"
35
41
  Yeptris::YAML.dump(attributes_to_serialize)
36
42
  end
37
43
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require "leptris"
5
+ require "leptris/xml/descriptor"
6
+ require "benchmark"
7
+
8
+ module PlanWalkProbe
9
+ class BulkItem < Lutaml::Model::Serializable
10
+ attribute :id, :integer
11
+ attribute :name, :string
12
+ attribute :tags, :string, collection: true
13
+
14
+ xml do
15
+ element "row"
16
+ map_attribute "id", to: :id
17
+ map_element "name", to: :name
18
+ map_element "tags", to: :tags
19
+ end
20
+ end
21
+
22
+ class BulkRoot < Lutaml::Model::Serializable
23
+ attribute :item, BulkItem, collection: true
24
+
25
+ xml do
26
+ element "iso"
27
+ map_element "row", to: :item
28
+ end
29
+ end
30
+ end
31
+
32
+ xml = File.read("/tmp/iso.xml")
33
+ Lutaml::Model::Config.adapter_for(:xml) # resolve adapter before benchmarking
34
+
35
+ measure = lambda do |label, &block|
36
+ elapsed = Benchmark.measure { 3.times(&block) }.real * 1000
37
+ puts "#{label}: %.2f ms total / %.2f ms each" % [elapsed, elapsed / 3]
38
+ end
39
+
40
+ measure.call("raw parse + descriptor walk") do
41
+ descriptor = Leptris::XML::Descriptor.build(
42
+ name: "iso",
43
+ children: [{ name: "row", kind: :collection }],
44
+ )
45
+ descriptor.walk(Leptris::XML.parse(xml).root)
46
+ end
47
+
48
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
49
+ measure.call("PlanWalk parse + 5000 model.new") do
50
+ result = Lutaml::Xml::PlanWalk.call(PlanWalkProbe::BulkRoot, xml)
51
+ raise "PlanWalk returned nil" unless result&.item&.size == 5000
52
+ end
53
+
54
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
55
+ measure.call("interpretive from_xml") do
56
+ result = PlanWalkProbe::BulkRoot.from_xml(xml)
57
+ raise "interpretive returned wrong size" unless result.item.size == 5000
58
+ end
data/lutaml-model.gemspec CHANGED
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_dependency "canon"
39
39
  spec.add_dependency "concurrent-ruby"
40
40
  spec.add_dependency "liquid", ">= 4.0", "< 6.0"
41
- spec.add_dependency "moxml", "~> 0.5.34"
41
+ spec.add_dependency "moxml", "~> 0.5.56"
42
42
  spec.add_dependency "ostruct"
43
43
  spec.add_dependency "rubyzip", "~> 2.3"
44
44
  spec.add_dependency "thor"
@@ -75,7 +75,7 @@ RSpec.describe "XML plan fast path" do
75
75
  end
76
76
  end
77
77
 
78
- it "falls back for non-compilable models" do
78
+ it "defers custom-method rules through fragment interpretation" do
79
79
  klass = Class.new(Lutaml::Model::Serializable) do
80
80
  attribute :note, :string
81
81
 
@@ -85,12 +85,463 @@ RSpec.describe "XML plan fast path" do
85
85
  end
86
86
 
87
87
  def note_from(instance, value)
88
- instance.note = value.text
88
+ instance.note = value.text.upcase
89
89
  end
90
90
  end
91
91
 
92
92
  expect(Lutaml::Xml::PlanCompiler.compile(klass,
93
- Lutaml::Model::Config.default_register)).to be_nil
94
- expect(klass.from_xml("<doc><note>hi</note></doc>").note).to eq("hi")
93
+ Lutaml::Model::Config.default_register)).not_to be_nil
94
+ expect(klass.from_xml("<doc><note>hi</note></doc>").note).to eq("HI")
95
+ end
96
+
97
+ it "compiles multiple spellings in interpretive spelling-group order" do
98
+ klass = Class.new(Lutaml::Model::Serializable) do
99
+ attribute :val, :string, collection: true
100
+
101
+ xml do
102
+ element "d"
103
+ map_element %w[a b], to: :val
104
+ end
105
+ end
106
+
107
+ parsed = klass.from_xml("<d><a>1</a><b>2</b><a>3</a></d>")
108
+ expect(parsed.val).to eq(%w[1 3 2])
109
+ end
110
+
111
+ it "compiles multiple collection rows via callback routing" do
112
+ klass = Class.new(Lutaml::Model::Serializable) do
113
+ attribute :xs, :string, collection: true
114
+ attribute :ys, :string, collection: true
115
+
116
+ xml do
117
+ element "d"
118
+ map_element "x", to: :xs
119
+ map_element "y", to: :ys
120
+ end
121
+ end
122
+
123
+ parsed = klass.from_xml("<d><x>1</x><y>a</y><x>2</x><y>b</y></d>")
124
+ expect(parsed.xs).to eq(%w[1 2])
125
+ expect(parsed.ys).to eq(%w[a b])
126
+ end
127
+
128
+ it "splits delimited attributes" do
129
+ klass = Class.new(Lutaml::Model::Serializable) do
130
+ attribute :tags, :string, collection: true
131
+
132
+ xml do
133
+ element "d"
134
+ map_attribute "tags", to: :tags, delimiter: ","
135
+ end
136
+ end
137
+
138
+ expect(klass.from_xml('<d tags="a,b,c"/>').tags).to eq(%w[a b c])
139
+ end
140
+
141
+ it "compiles delegate rules with post-instance routing" do
142
+ target = Class.new(Lutaml::Model::Serializable) do
143
+ attribute :note, :string
144
+
145
+ xml do
146
+ element "tgt"
147
+ map_element "note", to: :note
148
+ end
149
+ end
150
+ klass = Class.new(Lutaml::Model::Serializable) do
151
+ attribute :tgt, target
152
+
153
+ xml do
154
+ element "doc"
155
+ map_element "note", to: :note, delegate: :tgt
156
+ end
157
+ end
158
+
159
+ expect(Lutaml::Xml::PlanCompiler.compile(klass,
160
+ Lutaml::Model::Config.default_register)).not_to be_nil
161
+ parsed = klass.from_xml("<doc><note>hi</note></doc>")
162
+ expect(parsed.tgt.note).to eq("hi")
163
+ end
164
+
165
+ it "routes a single collection row natively (hybrid routing)" do
166
+ plan = Lutaml::Xml::PlanCompiler.compile(
167
+ Class.new(Lutaml::Model::Serializable) do
168
+ attribute :xs, :string, collection: true
169
+ attribute :name, :string
170
+
171
+ xml do
172
+ element "d"
173
+ map_element "x", to: :xs
174
+ map_element "name", to: :name
175
+ end
176
+ end,
177
+ Lutaml::Model::Config.default_register,
178
+ )
179
+ expect(plan[:rows].map { |r| r[2] }).to include(:collection_native)
180
+ end
181
+
182
+ describe "newly compilable shapes" do
183
+ around do |example|
184
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
185
+ end
186
+
187
+ it "compiles namespace-qualified models and matches any bound prefix" do
188
+ ns = Class.new(Lutaml::Xml::W3c::XmlNamespace) do
189
+ uri "urn:probe"
190
+ prefix_default "p"
191
+ element_form_default :qualified
192
+ end
193
+ item = Class.new(Lutaml::Model::Serializable) do
194
+ attribute :name, :string
195
+ xml do
196
+ namespace ns
197
+ element "item"
198
+ map_element "name", to: :name
199
+ end
200
+ end
201
+ root = Class.new(Lutaml::Model::Serializable) do
202
+ attribute :item, item, collection: true
203
+ xml do
204
+ namespace ns
205
+ element "root"
206
+ map_element "item", to: :item
207
+ end
208
+ end
209
+ xml = %(<zz:root xmlns:zz="urn:probe"><zz:item><zz:name>A</zz:name></zz:item></zz:root>)
210
+
211
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
212
+ ref = root.from_xml(xml)
213
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
214
+ fast = root.from_xml(xml)
215
+ expect(fast.item.map(&:name)).to eq(ref.item.map(&:name))
216
+ end
217
+
218
+ it "reads cdata sections as text" do
219
+ klass = Class.new(Lutaml::Model::Serializable) do
220
+ attribute :t, :string
221
+ xml do
222
+ element "d"
223
+ map_element "t", to: :t, cdata: true
224
+ end
225
+ end
226
+ expect(klass.from_xml("<d><t><![CDATA[a <b> c]]></t></d>").t).to eq("a <b> c")
227
+ end
228
+
229
+ it "captures raw element subtrees verbatim" do
230
+ klass = Class.new(Lutaml::Model::Serializable) do
231
+ attribute :frag, :string
232
+ xml do
233
+ element "d"
234
+ map_element "frag", to: :frag, raw: :element
235
+ end
236
+ end
237
+ # Verbatim capture: the fast path returns the exact source
238
+ # subtree (the interpretive path re-serializes with indentation).
239
+ expect(klass.from_xml(%(<d><frag><x a="1">inner</x></frag></d>)).frag)
240
+ .to eq(%(<frag><x a="1">inner</x></frag>))
241
+ end
242
+
243
+ it "hydrates content runs on collection attributes" do
244
+ klass = Class.new(Lutaml::Model::Serializable) do
245
+ attribute :text, :string, collection: true
246
+ attribute :b, :string
247
+ xml do
248
+ element "p"
249
+ map_content to: :text
250
+ map_element "b", to: :b
251
+ end
252
+ end
253
+ parsed = klass.from_xml("<p>Hello <b>bold</b> world!</p>")
254
+ expect(parsed.text).to eq(["Hello ", " world!"])
255
+ expect(parsed.b).to eq("bold")
256
+ end
257
+ end
258
+
259
+ describe "ordered and mixed content" do
260
+ around do |example|
261
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
262
+ end
263
+
264
+ let(:emph_class) do
265
+ Class.new(Lutaml::Model::Serializable) do
266
+ attribute :text, :string
267
+
268
+ xml do
269
+ element "emph"
270
+ map_content to: :text
271
+ end
272
+ end
273
+ end
274
+
275
+ let(:mixed_class) do
276
+ emph = emph_class
277
+ Class.new(Lutaml::Model::Serializable) do
278
+ attribute :emph, emph, collection: true
279
+ attribute :text, :string, collection: true
280
+ attribute :kind, :string
281
+
282
+ xml do
283
+ element "para"
284
+ mixed_content
285
+ map_attribute "kind", to: :kind
286
+ map_element "emph", to: :emph
287
+ map_content to: :text
288
+ end
289
+ end
290
+ end
291
+
292
+ it "compiles mixed-content mappings and reconstructs element_order" do
293
+ xml = %(<para kind="a">Hi <emph>there</emph> bye</para>)
294
+
295
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
296
+ interpretive = mixed_class.from_xml(xml)
297
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
298
+ fast = mixed_class.from_xml(xml)
299
+
300
+ order_sig = ->(m) do
301
+ m.element_order.map { |e| [e.type, e.name, e.text_content] }
302
+ end
303
+ expect(order_sig.call(fast)).to eq(order_sig.call(interpretive))
304
+ expect(fast.kind).to eq("a")
305
+ expect(fast.text).to eq(interpretive.text)
306
+ expect(fast.emph.map(&:text)).to eq(interpretive.emph.map(&:text))
307
+ expect(fast.to_xml).to eq(interpretive.to_xml)
308
+ end
309
+
310
+ it "yields each_mixed_content equal to the interpretive path" do
311
+ xml = %(<para>Hi <emph>there</emph> bye</para>)
312
+
313
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
314
+ interpretive = mixed_class.from_xml(xml)
315
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
316
+ fast = mixed_class.from_xml(xml)
317
+
318
+ sig = ->(m) do
319
+ m.each_mixed_content.map { |i| i.is_a?(String) ? i : i.text }
320
+ end
321
+ expect(sig.call(fast)).to eq(sig.call(interpretive))
322
+ end
323
+
324
+ it "defers ordered nested children interpretively while the parent compiles" do
325
+ mixed = mixed_class
326
+ doc_class = Class.new(Lutaml::Model::Serializable) do
327
+ attribute :title, :string
328
+ attribute :para, mixed, collection: true
329
+
330
+ xml do
331
+ element "doc"
332
+ map_element "title", to: :title
333
+ map_element "para", to: :para
334
+ end
335
+ end
336
+ plan = Lutaml::Xml::PlanCompiler.compile(
337
+ doc_class, Lutaml::Model::Config.default_register
338
+ )
339
+ expect(plan).not_to be_nil
340
+ expect(plan[:rows].map { |r| r[2] }).to include(:ordered_deferred)
341
+
342
+ xml = %(<doc><title>T</title><para>Hi <emph>x</emph> bye</para><para>plain</para></doc>)
343
+ parsed = doc_class.from_xml(xml)
344
+ expect(parsed.title).to eq("T")
345
+ para = parsed.para.first
346
+ expect(para.text).to eq(["Hi ", " bye"])
347
+ expect(para.emph.map(&:text)).to eq(["x"])
348
+ expect(para.element_order.map { |e| [e.type, e.name] })
349
+ .to eq([["Text", "text"], ["Element", "emph"], ["Text", "text"]])
350
+ # absent collections materialize as [] (interpretive parity)
351
+ expect(parsed.para.last.emph).to eq([])
352
+ end
353
+
354
+ it "reconstructs element_order for ordered-only mappings" do
355
+ klass = Class.new(Lutaml::Model::Serializable) do
356
+ attribute :a, :string, collection: true
357
+ attribute :b, :string, collection: true
358
+
359
+ xml do
360
+ element "d"
361
+ ordered
362
+ map_element "a", to: :a
363
+ map_element "b", to: :b
364
+ end
365
+ end
366
+ xml = %(<d><a>1</a><b>x</b><a>2</a></d>)
367
+
368
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
369
+ interpretive = klass.from_xml(xml)
370
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
371
+ fast = klass.from_xml(xml)
372
+
373
+ expect(fast.a).to eq(interpretive.a)
374
+ expect(fast.b).to eq(interpretive.b)
375
+ expect(fast.element_order.map { |e| [e.type, e.name] })
376
+ .to eq(interpretive.element_order.map { |e| [e.type, e.name] })
377
+ end
378
+
379
+ it "joins content runs for non-collection content attributes" do
380
+ emph = emph_class
381
+ klass = Class.new(Lutaml::Model::Serializable) do
382
+ attribute :emph, emph, collection: true
383
+
384
+ xml do
385
+ element "para"
386
+ map_element "emph", to: :emph
387
+ end
388
+ end
389
+
390
+ parsed = klass.from_xml(%(<para><emph>bold</emph></para>))
391
+ expect(parsed.emph.first.text).to eq("bold")
392
+ end
393
+ end
394
+
395
+ describe "serialize fast path" do
396
+ around do |example|
397
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
398
+ end
399
+
400
+ it "serializes ordered models byte-equal from element_order" do
401
+ emph = Class.new(Lutaml::Model::Serializable) do
402
+ attribute :text, :string
403
+
404
+ xml do
405
+ element "emph"
406
+ map_content to: :text
407
+ end
408
+ end
409
+ para = Class.new(Lutaml::Model::Serializable) do
410
+ attribute :emph, emph, collection: true
411
+ attribute :text, :string, collection: true
412
+ attribute :kind, :string
413
+
414
+ xml do
415
+ element "para"
416
+ mixed_content
417
+ map_attribute "kind", to: :kind
418
+ map_element "emph", to: :emph
419
+ map_content to: :text
420
+ end
421
+ end
422
+ doc_class = Class.new(Lutaml::Model::Serializable) do
423
+ attribute :title, :string
424
+ attribute :para, para, collection: true
425
+
426
+ xml do
427
+ element "doc"
428
+ map_element "title", to: :title
429
+ map_element "para", to: :para
430
+ end
431
+ end
432
+ xml = %(<doc><title>T</title><para kind="a">Hi <emph>there</emph> bye <emph>again</emph>!</para><para>second</para></doc>)
433
+
434
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
435
+ fast = doc_class.from_xml(xml)
436
+ fast_out = fast.to_xml
437
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
438
+ interp_out = doc_class.from_xml(xml).to_xml
439
+
440
+ expect(fast_out).to eq(interp_out)
441
+ expect(fast_out).to include(%(<para kind="a">Hi <emph>there</emph> bye <emph>again</emph>!</para>))
442
+ end
443
+
444
+ it "reflects content mutations through the ordered fast serializer" do
445
+ para = Class.new(Lutaml::Model::Serializable) do
446
+ attribute :emph, :string, collection: true
447
+ attribute :text, :string, collection: true
448
+
449
+ xml do
450
+ element "para"
451
+ mixed_content
452
+ map_element "emph", to: :emph
453
+ map_content to: :text
454
+ end
455
+ end
456
+ xml = %(<para>Hi <emph>x</emph> bye</para>)
457
+
458
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
459
+ fast = para.from_xml(xml)
460
+ fast.text = ["MUTATED ", " tail"]
461
+ fast_out = fast.to_xml
462
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
463
+ interp = para.from_xml(xml)
464
+ interp.text = ["MUTATED ", " tail"]
465
+ expect(fast_out).to eq(interp.to_xml)
466
+ expect(fast_out).to eq("<para>MUTATED <emph>x</emph> tail</para>")
467
+ end
468
+
469
+ it "falls back interpretively for ordered instances without element_order" do
470
+ para = Class.new(Lutaml::Model::Serializable) do
471
+ attribute :emph, :string, collection: true
472
+ attribute :text, :string, collection: true
473
+
474
+ xml do
475
+ element "para"
476
+ mixed_content
477
+ map_element "emph", to: :emph
478
+ map_content to: :text
479
+ end
480
+ end
481
+ model = para.new(emph: ["z"], text: ["hello ", " world"])
482
+
483
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
484
+ out = model.to_xml
485
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
486
+ expect(out).to eq(model.to_xml)
487
+ expect(out).to eq("<para>hello <emph>z</emph> world</para>")
488
+ end
489
+
490
+ it "serializes byte-equal to the interpretive path" do
491
+ item = Class.new(Lutaml::Model::Serializable) do
492
+ attribute :id, :integer
493
+ attribute :name, :string
494
+ attribute :tags, :string, collection: true
495
+
496
+ xml do
497
+ element "item"
498
+ map_attribute "id", to: :id
499
+ map_element "name", to: :name
500
+ map_element "tag", to: :tags
501
+ end
502
+ end
503
+ root = Class.new(Lutaml::Model::Serializable) do
504
+ attribute :item, item, collection: true
505
+
506
+ xml do
507
+ element "root"
508
+ map_element "item", to: :item
509
+ end
510
+ end
511
+ model = root.new(item: [item.new(id: 1, name: "a", tags: %w[x y]),
512
+ item.new(id: 2, name: "b", tags: %w[z])])
513
+
514
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
515
+ interpretive = model.to_xml
516
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
517
+ expect(model.to_xml).to eq(interpretive)
518
+ end
519
+
520
+ it "round-trips through both fast paths" do
521
+ item = Class.new(Lutaml::Model::Serializable) do
522
+ attribute :id, :integer
523
+ attribute :name, :string
524
+
525
+ xml do
526
+ element "item"
527
+ map_attribute "id", to: :id
528
+ map_element "name", to: :name
529
+ end
530
+ end
531
+ root = Class.new(Lutaml::Model::Serializable) do
532
+ attribute :item, item, collection: true
533
+
534
+ xml do
535
+ element "root"
536
+ map_element "item", to: :item
537
+ end
538
+ end
539
+ model = root.new(item: [item.new(id: 7, name: "n")])
540
+
541
+ xml = model.to_xml
542
+ parsed = root.from_xml(xml)
543
+ expect(parsed.item.first.id).to eq(7)
544
+ expect(parsed.to_xml).to eq(xml)
545
+ end
95
546
  end
96
547
  end