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,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "XML plan fast path" do
6
+ let(:classes) do
7
+ item_class = Class.new(Lutaml::Model::Serializable) do
8
+ attribute :id, :integer
9
+ attribute :name, :string
10
+ attribute :tags, :string, collection: true
11
+
12
+ xml do
13
+ element "item"
14
+ map_attribute "id", to: :id
15
+ map_element "name", to: :name
16
+ map_element "tag", to: :tags
17
+ end
18
+ end
19
+ root_class = Class.new(Lutaml::Model::Serializable) do
20
+ attribute :item, item_class, collection: true
21
+
22
+ xml do
23
+ element "root"
24
+ map_element "item", to: :item
25
+ end
26
+ end
27
+ [item_class, root_class]
28
+ end
29
+
30
+ let(:item_class) { classes[0] }
31
+ let(:root_class) { classes[1] }
32
+
33
+ around do |example|
34
+ old = Lutaml::Model::Config.instance.xml_plan_fast_path
35
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
36
+ example.run
37
+ ensure
38
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old
39
+ end
40
+
41
+ it "is opt-in and off by default" do
42
+ expect(Lutaml::Model::Configuration.new.xml_plan_fast_path).to be(false)
43
+ end
44
+
45
+ it "hydrates equal to the interpretive path" do
46
+ xml = root_class.new(item: [
47
+ item_class.new(id: 1, name: "a", tags: %w[x y]),
48
+ item_class.new(id: 2, name: "b", tags: %w[z]),
49
+ ]).to_xml
50
+
51
+ expect(root_class.from_xml(xml).item).to eq(root_class.new(item: [
52
+ item_class.new(id: 1, name: "a", tags: %w[x y]),
53
+ item_class.new(id: 2, name: "b", tags: %w[z]),
54
+ ]).item)
55
+ end
56
+
57
+ it "matches the interpretive path on missing elements" do
58
+ xml = <<~XML
59
+ <root>
60
+ <item id="1"><name>A</name><tag>x</tag></item>
61
+ <item id="2"><tag>y</tag><tag>z</tag></item>
62
+ <item id="3"><name>C</name></item>
63
+ </root>
64
+ XML
65
+
66
+ parsed = root_class.from_xml(xml)
67
+ expect(parsed.item.map { |i| [i.id, i.name, i.tags] })
68
+ .to eq([[1, "A", %w[x]], [2, nil, %w[y z]], [3, "C", []]])
69
+ end
70
+
71
+ it "wraps engine parse errors as InvalidFormatError" do
72
+ Lutaml::Model::Config.with_adapter(xml: :leptris) do
73
+ expect { root_class.from_xml("<root><item>") }
74
+ .to raise_error(Lutaml::Model::InvalidFormatError)
75
+ end
76
+ end
77
+
78
+ it "defers custom-method rules through fragment interpretation" do
79
+ klass = Class.new(Lutaml::Model::Serializable) do
80
+ attribute :note, :string
81
+
82
+ xml do
83
+ element "doc"
84
+ map_element "note", to: :note, with: { from: :note_from }
85
+ end
86
+
87
+ def note_from(instance, value)
88
+ instance.note = value.text.upcase
89
+ end
90
+ end
91
+
92
+ expect(Lutaml::Xml::PlanCompiler.compile(klass,
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
546
+ end
547
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/xml/plan_walk"
5
+
6
+ RSpec.describe "XML PlanWalk consumer surface" do
7
+ around do |example|
8
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
9
+ end
10
+
11
+ let(:item_class) do
12
+ Class.new(Lutaml::Model::Serializable) do
13
+ attribute :id, :integer
14
+ attribute :name, :string
15
+ attribute :tags, :string, collection: true
16
+
17
+ xml do
18
+ element "row"
19
+ map_attribute "id", to: :id
20
+ map_element "name", to: :name
21
+ map_element "tags", to: :tags
22
+ end
23
+ end
24
+ end
25
+
26
+ it "hydrates equal to the interpretive path on a single-row document" do
27
+ xml = %(<row id="7"><name>Alice</name><tags>x</tags><tags>y</tags></row>)
28
+ walked = Lutaml::Xml::PlanWalk.call(item_class, xml)
29
+ expected = item_class.from_xml(xml)
30
+
31
+ expect(walked).to be_a(item_class)
32
+ expect(walked.id).to eq(expected.id)
33
+ expect(walked.name).to eq(expected.name)
34
+ expect(walked.tags).to eq(expected.tags)
35
+ end
36
+
37
+ it "returns an Array for collection roots" do
38
+ xml = %(<root><row id="1"><name>a</name></row><row id="2"><name>b</name></row></root>)
39
+ item_type = item_class
40
+ root_class = Class.new(Lutaml::Model::Serializable) do
41
+ attribute :item, item_type, collection: true
42
+
43
+ xml do
44
+ element "root"
45
+ map_element "row", to: :item
46
+ end
47
+ end
48
+
49
+ walked = Lutaml::Xml::PlanWalk.call(root_class, xml)
50
+ expected = root_class.from_xml(xml)
51
+
52
+ expect(walked.item).to be_an(Array)
53
+ expect(walked.item.map(&:id)).to eq(expected.item.map(&:id))
54
+ expect(walked.item.map(&:name)).to eq(expected.item.map(&:name))
55
+ end
56
+
57
+ it "walks and hydrates a 5000-row document in under the interpretive cost" do
58
+ xml = +"<root>"
59
+ 5000.times do |i|
60
+ xml << %(<row id="#{i}"><name>r-#{i}</name><tags>a</tags><tags>b</tags></row>)
61
+ end
62
+ xml << "</root>"
63
+
64
+ item_type = item_class
65
+ root_class = Class.new(Lutaml::Model::Serializable) do
66
+ attribute :item, item_type, collection: true
67
+
68
+ xml do
69
+ element "root"
70
+ map_element "row", to: :item
71
+ end
72
+ end
73
+
74
+ walked = Lutaml::Xml::PlanWalk.call(root_class, xml)
75
+ interpretive = root_class.from_xml(xml)
76
+ expect(walked.item.size).to eq(interpretive.item.size)
77
+ expect(walked.item.map(&:id)).to eq(interpretive.item.map(&:id))
78
+ end
79
+
80
+ it "compiles delegate mappings for post-instance routing" do
81
+ # delegates compile and route after the target instance exists
82
+ target = Class.new(Lutaml::Model::Serializable) do
83
+ attribute :note, :string
84
+ xml do
85
+ element "tgt"
86
+ map_element "note", to: :note
87
+ end
88
+ end
89
+ klass = Class.new(Lutaml::Model::Serializable) do
90
+ attribute :tgt, target
91
+ xml do
92
+ element "doc"
93
+ map_element "note", to: :note, delegate: :tgt
94
+ end
95
+ end
96
+
97
+ expect(Lutaml::Xml::PlanCompiler.compile(klass,
98
+ Lutaml::Model::Config.default_register)).not_to be_nil
99
+ expect(Lutaml::Xml::PlanWalk.call(klass, "<doc><note>x</note></doc>").tgt.note)
100
+ .to eq("x")
101
+ end
102
+ end
@@ -120,6 +120,19 @@ RSpec.describe Lutaml::Xml do
120
120
  hide_const("Ox") if Object.const_defined?(:Ox)
121
121
  hide_const("Oga") if Object.const_defined?(:Oga)
122
122
  hide_const("REXML") if Object.const_defined?(:REXML)
123
+ # When the leptris gem is in the bundle, moxml prefers it over every
124
+ # other engine; the legacy-detection contexts below must opt out to
125
+ # exercise the fallback chain.
126
+ allow(Moxml::Config).to receive(:leptris_preferred_available?)
127
+ .and_return(false)
128
+ end
129
+
130
+ context "when the leptris engine is in the bundle" do
131
+ it "prefers :leptris" do
132
+ allow(Moxml::Config).to receive(:leptris_preferred_available?)
133
+ .and_return(true)
134
+ expect(described_class.detect_xml_adapter).to eq(:leptris)
135
+ end
123
136
  end
124
137
 
125
138
  context "when Nokogiri is available" do