moxml 0.5.61 → 0.5.63
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 +4 -4
- data/.github/workflows/rake.yml +5 -0
- data/Rakefile +10 -0
- data/benchmark/plan_bench.rb +140 -0
- data/lib/moxml/adapter/base.rb +19 -5
- data/lib/moxml/adapter/leptris.rb +26 -0
- data/lib/moxml/adapter/libxml.rb +32 -0
- data/lib/moxml/adapter/nokogiri.rb +23 -0
- data/lib/moxml/adapter/oga.rb +28 -0
- data/lib/moxml/adapter/ox.rb +29 -0
- data/lib/moxml/adapter/rexml.rb +18 -0
- data/lib/moxml/struct_plan.rb +81 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml.rb +1 -0
- data/spec/moxml/plan_conformance_spec.rb +80 -0
- data/spec/moxml/struct_plan_spec.rb +69 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60493a9fbb6b03a4db31f83e99f269c5d5cf1797f5e0e94f4b6139616905e45e
|
|
4
|
+
data.tar.gz: 4df319d1a67b69f2a08e99cf338131c55584b1e68cb4b46687a23c2f6329263c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6690afe362a4af5781c54271c29daf1af651a6bc34975fd078a9c1d56ec56ac6c5af13a63746a801c206162aa25c7d09314f5bca55db48d356d83586466eda7f
|
|
7
|
+
data.tar.gz: a53a4a73958a40338ed54fbeb6c9098264e5f480b8125888b7471c9d6d11ec9dde455fc35e7de8847c3246fa834e607ab16f8eed5f2f47089547a45ff5b17f8b
|
data/.github/workflows/rake.yml
CHANGED
data/Rakefile
CHANGED
|
@@ -253,6 +253,16 @@ namespace :benchmark do
|
|
|
253
253
|
sh "ruby", "benchmark/pipeline_bench.rb"
|
|
254
254
|
end
|
|
255
255
|
|
|
256
|
+
# Plan-bench regression lock (#249 follow-up): Moxml::Plan vs the
|
|
257
|
+
# wrapper walk vs raw nokogiri on the consumer shape. With
|
|
258
|
+
# LEPTRIS_BENCH_LOCK=1 (CI) it exits 1 when plan time exceeds
|
|
259
|
+
# 1.5x raw nokogiri — the "raw-nokogiri CPU parity" regression
|
|
260
|
+
# tripwire.
|
|
261
|
+
desc "Plan materialization benchmark with raw-nokogiri parity lock"
|
|
262
|
+
task :plan do
|
|
263
|
+
sh "bundle exec ruby benchmark/plan_bench.rb"
|
|
264
|
+
end
|
|
265
|
+
|
|
256
266
|
desc "Generate adapter benchmark report"
|
|
257
267
|
task :report do
|
|
258
268
|
ruby "benchmarks/generate_report.rb"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Plan-bench regression lock (leptris follow-up to #249): the plan
|
|
4
|
+
# path must stay at or near raw-nokogiri CPU parity on the consumer
|
|
5
|
+
# shape. Run via `rake benchmark:plan` or with LEPTRIS_BENCH_LOCK=1
|
|
6
|
+
# to enforce the ceiling (CI): plan <= nokogiri * 1.5 — failures
|
|
7
|
+
# mean an adapter or plan change regressed the compiled walk.
|
|
8
|
+
require "moxml"
|
|
9
|
+
require "nokogiri"
|
|
10
|
+
|
|
11
|
+
Field = Struct.new(:name, :unit, :value)
|
|
12
|
+
Record = Struct.new(:id, :kind, :fields)
|
|
13
|
+
Catalog = Struct.new(:records)
|
|
14
|
+
|
|
15
|
+
parts = [%(<?xml version="1.0"?><catalog>)]
|
|
16
|
+
150.times do |i|
|
|
17
|
+
parts << %(<record id="r#{i}" kind="k#{i % 3}">)
|
|
18
|
+
6.times { |j| parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>) }
|
|
19
|
+
parts << %(</record>)
|
|
20
|
+
end
|
|
21
|
+
parts << %(</catalog>)
|
|
22
|
+
XML = parts.join
|
|
23
|
+
|
|
24
|
+
CTX = Moxml.new(:leptris)
|
|
25
|
+
|
|
26
|
+
STRUCT_PLAN = Moxml::StructPlan.new do
|
|
27
|
+
element "record", Record, attrs: { "id" => :id, "kind" => :kind }, children: :fields
|
|
28
|
+
element "field", Field, attrs: { "name" => :name, "unit" => :unit }, text: :value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
PLAN = Moxml::Plan.new do
|
|
32
|
+
on("record") { |attrs, _t, fields| Record.new(attrs["id"], attrs["kind"], fields) }
|
|
33
|
+
on("field") { |attrs, text, _k| Field.new(attrs["name"], attrs["unit"], text) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def plan_run
|
|
37
|
+
records = PLAN.parse(XML, CTX)
|
|
38
|
+
raise "shape" unless records.size == 150 && records[7].fields[3].value == "value 7.3"
|
|
39
|
+
|
|
40
|
+
records
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def struct_run
|
|
44
|
+
records = STRUCT_PLAN.parse(XML, CTX)
|
|
45
|
+
raise "shape" unless records.size == 150 && records[7].fields[3].value == "value 7.3"
|
|
46
|
+
|
|
47
|
+
records
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wrapper_run
|
|
51
|
+
doc = CTX.parse(XML)
|
|
52
|
+
catalog = Catalog.new([])
|
|
53
|
+
doc.root.children.each do |rec|
|
|
54
|
+
next unless rec.is_a?(Moxml::Element)
|
|
55
|
+
|
|
56
|
+
record = Record.new(rec["id"], rec["kind"], [])
|
|
57
|
+
rec.children.each do |f|
|
|
58
|
+
next unless f.is_a?(Moxml::Element)
|
|
59
|
+
|
|
60
|
+
record.fields << Field.new(f["name"], f["unit"], f.text)
|
|
61
|
+
end
|
|
62
|
+
catalog.records << record
|
|
63
|
+
end
|
|
64
|
+
raise "shape" unless catalog.records.size == 150
|
|
65
|
+
|
|
66
|
+
catalog
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def nokogiri_run
|
|
70
|
+
doc = Nokogiri::XML(XML)
|
|
71
|
+
catalog = Catalog.new([])
|
|
72
|
+
doc.root.children.each do |rec|
|
|
73
|
+
next unless rec.is_a?(Nokogiri::XML::Element)
|
|
74
|
+
|
|
75
|
+
record = Record.new(rec["id"], rec["kind"], [])
|
|
76
|
+
rec.children.each do |f|
|
|
77
|
+
next unless f.is_a?(Nokogiri::XML::Element)
|
|
78
|
+
|
|
79
|
+
record.fields << Field.new(f["name"], f["unit"], f.text)
|
|
80
|
+
end
|
|
81
|
+
catalog.records << record
|
|
82
|
+
end
|
|
83
|
+
raise "shape" unless catalog.records.size == 150
|
|
84
|
+
|
|
85
|
+
catalog
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
REPS = 200
|
|
89
|
+
|
|
90
|
+
def bench(&block)
|
|
91
|
+
times = []
|
|
92
|
+
allocs = []
|
|
93
|
+
REPS.times do
|
|
94
|
+
GC.start
|
|
95
|
+
a0 = GC.stat(:total_allocated_objects)
|
|
96
|
+
t0 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
|
|
97
|
+
yield
|
|
98
|
+
times << (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - t0)
|
|
99
|
+
allocs << (GC.stat(:total_allocated_objects) - a0)
|
|
100
|
+
end
|
|
101
|
+
[times.sort[times.size / 2] * 1e6, allocs.min]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The parity lock needs the lean plan rows (binding 1.9.194.2+);
|
|
105
|
+
# on older bindings the plan runs the generic fallback and the
|
|
106
|
+
# ceiling is meaningless — report and exit clean.
|
|
107
|
+
if Gem::Version.new(Leptris::VERSION) < Gem::Version.new("1.9.194.2")
|
|
108
|
+
puts "SKIP parity lock: leptris #{Leptris::VERSION} lacks snapshot_rows (needs 1.9.194.2)"
|
|
109
|
+
exit 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# warm
|
|
113
|
+
plan_run
|
|
114
|
+
struct_run
|
|
115
|
+
wrapper_run
|
|
116
|
+
nokogiri_run
|
|
117
|
+
|
|
118
|
+
struct_us = nil
|
|
119
|
+
struct_allocs = nil
|
|
120
|
+
if Gem::Version.new(Leptris::VERSION) >= Gem::Version.new("1.9.197.1")
|
|
121
|
+
struct_us, struct_allocs = bench { struct_run }
|
|
122
|
+
end
|
|
123
|
+
plan_us, plan_allocs = bench { plan_run }
|
|
124
|
+
wrap_us, wrap_allocs = bench { wrapper_run }
|
|
125
|
+
nk_us, nk_allocs = bench { nokogiri_run }
|
|
126
|
+
|
|
127
|
+
if struct_us
|
|
128
|
+
puts format("struct (parse+materialize) %<t>8.0f us %<a>7d allocs",
|
|
129
|
+
t: struct_us, a: struct_allocs)
|
|
130
|
+
end
|
|
131
|
+
puts format("plan (parse+materialize) %<t>8.0f us %<a>7d allocs",
|
|
132
|
+
t: plan_us, a: plan_allocs)
|
|
133
|
+
puts format("wrapper (parse+walk) %<t>8.0f us %<a>7d allocs",
|
|
134
|
+
t: wrap_us, a: wrap_allocs)
|
|
135
|
+
puts format("nokogiri (parse+walk) %<t>8.0f us %<a>7d allocs",
|
|
136
|
+
t: nk_us, a: nk_allocs)
|
|
137
|
+
puts format("plan/nokogiri %<r>.2fx (ceiling 1.50x for the CI lock)",
|
|
138
|
+
r: plan_us / nk_us)
|
|
139
|
+
|
|
140
|
+
exit(1) if ENV["LEPTRIS_BENCH_LOCK"] && plan_us > nk_us * 1.5
|
data/lib/moxml/adapter/base.rb
CHANGED
|
@@ -358,15 +358,29 @@ namespace_validation_mode: :strict)
|
|
|
358
358
|
nil
|
|
359
359
|
end
|
|
360
360
|
|
|
361
|
-
# Plan row stream (Moxml::Plan)
|
|
362
|
-
# (flat [k, v, ...]), first-text, depth|
|
|
363
|
-
# document order (pre-order)
|
|
364
|
-
#
|
|
365
|
-
#
|
|
361
|
+
# Plan row stream (Moxml::Plan) — ADAPTER CONTRACT. Yields
|
|
362
|
+
# |name, attrs_pairs (flat [k, v, ...]), first-text, depth|
|
|
363
|
+
# per element in document order (pre-order); first-text is
|
|
364
|
+
# the element's first text child or nil. Adapters implement
|
|
365
|
+
# this natively off their engine nodes (nokogiri, ox, oga,
|
|
366
|
+
# rexml, libxml, leptris do) — Moxml::Plan executes entirely
|
|
367
|
+
# on these rows with no wrapper materialization. This default
|
|
368
|
+
# returns nil, which makes Moxml::Plan fall back to the
|
|
369
|
+
# generic wrapper walk — correct but slow; last resort only.
|
|
366
370
|
def plan_rows(_native)
|
|
367
371
|
nil
|
|
368
372
|
end
|
|
369
373
|
|
|
374
|
+
# Struct-plan executor (Moxml::StructPlan): +spec+ compiles
|
|
375
|
+
# the consumer's shape as {name => [Struct class, attrs
|
|
376
|
+
# Hash (name => slot Symbol), text slot, children slot]}.
|
|
377
|
+
# Returns the array of top-level minted structs, or nil when
|
|
378
|
+
# the adapter has no C executor (the StructPlan then runs
|
|
379
|
+
# its Moxml::Plan fallback over plan_rows).
|
|
380
|
+
def plan_structs(_native, _spec)
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
|
|
370
384
|
# Deterministic native-memory release for adapters backed by
|
|
371
385
|
# C trees (issue #134). GC-managed engines no-op; released
|
|
372
386
|
# documents raise the engine's use-after-free error on
|
|
@@ -537,6 +537,32 @@ module Moxml
|
|
|
537
537
|
NATIVE_READ_LAYER &&
|
|
538
538
|
Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.193.4")
|
|
539
539
|
|
|
540
|
+
# leptris-ruby#266 (binding 1.9.197.1): Native.plan_structs —
|
|
541
|
+
# the C struct executor behind Moxml::StructPlan.
|
|
542
|
+
NATIVE_PLAN_STRUCTS =
|
|
543
|
+
Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.197.1")
|
|
544
|
+
|
|
545
|
+
def self.plan_structs(native, spec)
|
|
546
|
+
return nil unless NATIVE_PLAN_STRUCTS
|
|
547
|
+
|
|
548
|
+
doc = if native.is_a?(::Leptris::XML::Document)
|
|
549
|
+
native
|
|
550
|
+
else
|
|
551
|
+
doc_for(native)
|
|
552
|
+
end
|
|
553
|
+
return nil if doc.nil? || attachments.get(doc, :entity_markers)
|
|
554
|
+
|
|
555
|
+
root_binding = if native.is_a?(::Leptris::XML::Document)
|
|
556
|
+
doc.root
|
|
557
|
+
else
|
|
558
|
+
to_binding(native)
|
|
559
|
+
end
|
|
560
|
+
return nil unless root_binding
|
|
561
|
+
|
|
562
|
+
::Leptris::XML::Native.plan_structs(doc, root_binding.c_address,
|
|
563
|
+
spec)
|
|
564
|
+
end
|
|
565
|
+
|
|
540
566
|
def self.plan_rows(native)
|
|
541
567
|
return nil unless NATIVE_READ_LAYER
|
|
542
568
|
|
data/lib/moxml/adapter/libxml.rb
CHANGED
|
@@ -1312,6 +1312,38 @@ module Moxml
|
|
|
1312
1312
|
dst[attr_name] = attr.value
|
|
1313
1313
|
end
|
|
1314
1314
|
end
|
|
1315
|
+
|
|
1316
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
1317
|
+
# rows straight off the LibXML nodes — no wrapper minting.
|
|
1318
|
+
def plan_rows(native)
|
|
1319
|
+
root = if native.is_a?(::LibXML::XML::Document)
|
|
1320
|
+
r = native.root
|
|
1321
|
+
return nil unless r
|
|
1322
|
+
|
|
1323
|
+
r
|
|
1324
|
+
else
|
|
1325
|
+
native
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
walk = lambda do |el, depth|
|
|
1329
|
+
attrs = []
|
|
1330
|
+
el.attributes.each do |attr|
|
|
1331
|
+
attrs << attr.name << attr.value
|
|
1332
|
+
end
|
|
1333
|
+
first_text = nil
|
|
1334
|
+
el.children.each do |c|
|
|
1335
|
+
first_text ||= c.content if c.text?
|
|
1336
|
+
end
|
|
1337
|
+
yield(el.name, attrs, first_text, depth)
|
|
1338
|
+
el.children.each do |c|
|
|
1339
|
+
walk.call(c, depth + 1) if c.element?
|
|
1340
|
+
end
|
|
1341
|
+
end
|
|
1342
|
+
walk.call(root, 0)
|
|
1343
|
+
true
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
public :plan_rows
|
|
1315
1347
|
end
|
|
1316
1348
|
|
|
1317
1349
|
# Bridge between LibXML SAX and Moxml SAX
|
|
@@ -516,6 +516,25 @@ module Moxml
|
|
|
516
516
|
attachments.set(native_doc, :xml_decl, nil)
|
|
517
517
|
end
|
|
518
518
|
|
|
519
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
520
|
+
# rows |name, attrs_pairs, first-text, depth| straight off
|
|
521
|
+
# the libxml2-backed nodes — no wrapper minting.
|
|
522
|
+
def plan_rows(native)
|
|
523
|
+
root = native.is_a?(::Nokogiri::XML::Document) ? native.root : native
|
|
524
|
+
return nil unless root
|
|
525
|
+
|
|
526
|
+
walk = lambda do |el, depth|
|
|
527
|
+
attrs = []
|
|
528
|
+
el.attribute_nodes.each { |a| attrs << a.name << a.value }
|
|
529
|
+
first_text = nil
|
|
530
|
+
el.children.each { |c| first_text ||= c.content if c.text? }
|
|
531
|
+
yield(el.name, attrs, first_text, depth)
|
|
532
|
+
el.element_children.each { |c| walk.call(c, depth + 1) }
|
|
533
|
+
end
|
|
534
|
+
walk.call(root, 0)
|
|
535
|
+
true
|
|
536
|
+
end
|
|
537
|
+
|
|
519
538
|
private
|
|
520
539
|
|
|
521
540
|
def build_declaration_attrs(version, encoding, standalone)
|
|
@@ -533,6 +552,10 @@ module Moxml
|
|
|
533
552
|
hsh.merge(attr_name => value)
|
|
534
553
|
end
|
|
535
554
|
end
|
|
555
|
+
|
|
556
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
557
|
+
# rows |name, attrs_pairs, first-text, depth| straight off
|
|
558
|
+
# the libxml2-backed nodes — no wrapper minting.
|
|
536
559
|
end
|
|
537
560
|
|
|
538
561
|
# Bridge between Nokogiri SAX and Moxml SAX
|
data/lib/moxml/adapter/oga.rb
CHANGED
|
@@ -643,6 +643,34 @@ module Moxml
|
|
|
643
643
|
|
|
644
644
|
::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml
|
|
645
645
|
end
|
|
646
|
+
|
|
647
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
648
|
+
# rows over Oga's Ruby node objects.
|
|
649
|
+
def plan_rows(native)
|
|
650
|
+
root = if native.is_a?(::Oga::XML::Document)
|
|
651
|
+
native.children.find { |c| c.is_a?(::Oga::XML::Element) }
|
|
652
|
+
else
|
|
653
|
+
native
|
|
654
|
+
end
|
|
655
|
+
return nil unless root.is_a?(::Oga::XML::Element)
|
|
656
|
+
|
|
657
|
+
walk = lambda do |el, depth|
|
|
658
|
+
attrs = []
|
|
659
|
+
el.attributes.each { |a| attrs << a.name << a.value }
|
|
660
|
+
first_text = nil
|
|
661
|
+
el.children.each do |c|
|
|
662
|
+
first_text ||= c.text if c.is_a?(::Oga::XML::Text)
|
|
663
|
+
end
|
|
664
|
+
yield(el.name, attrs, first_text, depth)
|
|
665
|
+
el.children.each do |c|
|
|
666
|
+
walk.call(c, depth + 1) if c.is_a?(::Oga::XML::Element)
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
walk.call(root, 0)
|
|
670
|
+
true
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
public :plan_rows
|
|
646
674
|
end
|
|
647
675
|
|
|
648
676
|
# Bridge between a parsed Oga DOM and Moxml SAX events.
|
data/lib/moxml/adapter/ox.rb
CHANGED
|
@@ -955,6 +955,35 @@ module Moxml
|
|
|
955
955
|
output << "</#{elem.name}>"
|
|
956
956
|
output
|
|
957
957
|
end
|
|
958
|
+
|
|
959
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
960
|
+
# rows over Ox's Ruby node objects.
|
|
961
|
+
def plan_rows(native)
|
|
962
|
+
root = if native.is_a?(::Ox::Document)
|
|
963
|
+
nodes = native.nodes.grep(::Ox::Element)
|
|
964
|
+
nodes.first
|
|
965
|
+
else
|
|
966
|
+
native
|
|
967
|
+
end
|
|
968
|
+
return nil unless root.is_a?(::Ox::Element)
|
|
969
|
+
|
|
970
|
+
walk = lambda do |el, depth|
|
|
971
|
+
attrs = []
|
|
972
|
+
(el.attributes || {}).each { |k, v| attrs << k.to_s << v.to_s }
|
|
973
|
+
first_text = nil
|
|
974
|
+
(el.nodes || []).each do |c|
|
|
975
|
+
first_text ||= c if c.is_a?(::String)
|
|
976
|
+
end
|
|
977
|
+
yield(el.name.to_s, attrs, first_text, depth)
|
|
978
|
+
(el.nodes || []).each do |c|
|
|
979
|
+
walk.call(c, depth + 1) if c.is_a?(::Ox::Element)
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
walk.call(root, 0)
|
|
983
|
+
true
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
public :plan_rows
|
|
958
987
|
end
|
|
959
988
|
end
|
|
960
989
|
|
data/lib/moxml/adapter/rexml.rb
CHANGED
|
@@ -694,6 +694,24 @@ module Moxml
|
|
|
694
694
|
)
|
|
695
695
|
formatter.write(node, output)
|
|
696
696
|
end
|
|
697
|
+
|
|
698
|
+
# Plan row stream (Moxml::Plan contract): pre-order element
|
|
699
|
+
# rows over REXML's Ruby node objects.
|
|
700
|
+
def plan_rows(native)
|
|
701
|
+
root = native.is_a?(::REXML::Document) ? native.root : native
|
|
702
|
+
return nil unless root
|
|
703
|
+
|
|
704
|
+
walk = lambda do |el, depth|
|
|
705
|
+
attrs = []
|
|
706
|
+
el.attributes.each { |k, v| attrs << k << v }
|
|
707
|
+
yield(el.name, attrs, el.text, depth)
|
|
708
|
+
el.elements.each { |c| walk.call(c, depth + 1) }
|
|
709
|
+
end
|
|
710
|
+
walk.call(root, 0)
|
|
711
|
+
true
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
public :plan_rows
|
|
697
715
|
end
|
|
698
716
|
end
|
|
699
717
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# Struct-compiled materialization — the fully compiled grammar:
|
|
5
|
+
# declare the document shape once as STRUCTS; adapters with a C
|
|
6
|
+
# executor (leptris 1.9.197.1+) mint the typed objects directly
|
|
7
|
+
# with zero Ruby frames per element; everything else falls back to
|
|
8
|
+
# an equivalent Moxml::Plan over the row stream (spec-pinned
|
|
9
|
+
# equal).
|
|
10
|
+
#
|
|
11
|
+
# plan = Moxml::StructPlan.new do
|
|
12
|
+
# element "record", Record, attrs: { "id" => :id, "kind" => :kind },
|
|
13
|
+
# children: :fields
|
|
14
|
+
# element "field", Field, attrs: { "name" => :name, "unit" => :unit },
|
|
15
|
+
# text: :value
|
|
16
|
+
# end
|
|
17
|
+
# records = plan.parse(xml, ctx) # Array of Record
|
|
18
|
+
#
|
|
19
|
+
# Slot symbols must be the Struct's member names. Semantics equal
|
|
20
|
+
# Moxml::Plan: unmatched elements are barriers — their matched
|
|
21
|
+
# descendants surface at top level.
|
|
22
|
+
class StructPlan
|
|
23
|
+
Element = Struct.new(:klass, :attrs, :text, :children)
|
|
24
|
+
|
|
25
|
+
def initialize(&block)
|
|
26
|
+
@elements = {}
|
|
27
|
+
instance_eval(&block) if block
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Registers a struct element. +attrs+ maps attribute names to
|
|
31
|
+
# member slots; +text+ and +children+ name the member slots
|
|
32
|
+
# receiving the first text child and the matched children array.
|
|
33
|
+
# Returns self so registrations chain.
|
|
34
|
+
def element(name, klass, attrs: {}, text: nil, children: nil)
|
|
35
|
+
@elements[name] = Element.new(klass, attrs, text, children)
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The compiled spec the C executor consumes:
|
|
40
|
+
# {name => [klass, attrs, text slot, children slot]}.
|
|
41
|
+
def compile
|
|
42
|
+
@elements.each_with_object({}) do |(name, el), spec|
|
|
43
|
+
spec[name] = [el.klass, el.attrs, el.text, el.children]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse(xml, context)
|
|
48
|
+
materialize(context.parse(xml))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def materialize(node)
|
|
52
|
+
adapter = node.context.config.adapter
|
|
53
|
+
roots = adapter.plan_structs(node.native, compile)
|
|
54
|
+
return roots if roots
|
|
55
|
+
|
|
56
|
+
plan.materialize(node)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# The fallback: the same element table as block handlers over
|
|
62
|
+
# the plan row stream. instance_eval'd DSL — capture the table
|
|
63
|
+
# in a local (the Plan's self has no @elements).
|
|
64
|
+
def plan
|
|
65
|
+
@plan ||= begin
|
|
66
|
+
elements = @elements
|
|
67
|
+
Moxml::Plan.new do
|
|
68
|
+
elements.each do |name, el|
|
|
69
|
+
on(name) do |attrs, text, children|
|
|
70
|
+
obj = el.klass.new
|
|
71
|
+
el.attrs.each { |aname, slot| obj[slot] = attrs[aname] }
|
|
72
|
+
obj[el.text] = text if el.text
|
|
73
|
+
obj[el.children] = children if el.children
|
|
74
|
+
obj
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/moxml/version.rb
CHANGED
data/lib/moxml.rb
CHANGED
|
@@ -94,6 +94,7 @@ module Moxml
|
|
|
94
94
|
autoload :XmlEmitter, "moxml/xml_emitter"
|
|
95
95
|
autoload :Materializer, "moxml/materializer"
|
|
96
96
|
autoload :Plan, "moxml/plan"
|
|
97
|
+
autoload :StructPlan, "moxml/struct_plan"
|
|
97
98
|
autoload :Adapter, "moxml/adapter"
|
|
98
99
|
autoload :XPath, "moxml/xpath"
|
|
99
100
|
autoload :SAX, "moxml/sax"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Moxml::Plan adapter-contract conformance (leptris follow-up to
|
|
6
|
+
# #249): every adapter must produce IDENTICAL plan results on the
|
|
7
|
+
# same document, whether its plan_rows is a bulk C stream (leptris,
|
|
8
|
+
# nokogiri, libxml) or a Ruby-native walk (ox, oga, rexml).
|
|
9
|
+
PlanRow = Struct.new(:id, :kind, :fields)
|
|
10
|
+
PlanCell = Struct.new(:name, :unit, :value)
|
|
11
|
+
|
|
12
|
+
RSpec.describe "Moxml::Plan adapter conformance" do
|
|
13
|
+
# Single-line fixture: no inter-element whitespace text, so
|
|
14
|
+
# engines that differ on insignificant text still agree.
|
|
15
|
+
let(:xml) do
|
|
16
|
+
'<catalog><record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record><record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record></catalog>'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
let(:plan) do
|
|
20
|
+
Moxml::Plan.new do
|
|
21
|
+
on("record") do |attrs, _text, fields|
|
|
22
|
+
PlanRow.new(attrs["id"], attrs["kind"], fields)
|
|
23
|
+
end
|
|
24
|
+
on("field") do |attrs, text, _kids|
|
|
25
|
+
PlanCell.new(attrs["name"], attrs["unit"], text)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
31
|
+
|
|
32
|
+
it "executes on every available adapter" do
|
|
33
|
+
available = ADAPTERS.select do |a|
|
|
34
|
+
ctx = Moxml.new(a)
|
|
35
|
+
ctx.parse(xml)
|
|
36
|
+
true
|
|
37
|
+
rescue StandardError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
expect(available).not_to be_empty
|
|
41
|
+
|
|
42
|
+
results = available.to_h { |a| [a, plan.parse(xml, Moxml.new(a))] }
|
|
43
|
+
|
|
44
|
+
baseline = results[available.first]
|
|
45
|
+
results.each do |adapter, rows|
|
|
46
|
+
expect(rows).to eq(baseline), "adapter #{adapter} diverged"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
expect(baseline.size).to eq(2)
|
|
50
|
+
expect(baseline[0].fields.map(&:value)).to eq(["v 0.0", "v 0.1"])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "skips no element and preserves order" do
|
|
54
|
+
rows = plan.parse(xml, Moxml.new(:nokogiri))
|
|
55
|
+
|
|
56
|
+
expect(rows.map(&:id)).to eq(%w[r0 r1])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "handles depth-3 nesting identically" do
|
|
60
|
+
deep = Moxml::Plan.new do
|
|
61
|
+
on("catalog") { |_a, _t, records| records }
|
|
62
|
+
on("record") { |attrs, _t, fields| [attrs["id"], fields] }
|
|
63
|
+
on("field") { |attrs, text, _| [attrs["name"], text] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ADAPTERS.each do |a|
|
|
67
|
+
begin
|
|
68
|
+
ctx = Moxml.new(a)
|
|
69
|
+
ctx.parse(xml)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
out = deep.parse(xml, ctx)
|
|
74
|
+
expect(out[0]).to eq(
|
|
75
|
+
[["r0", [["f0", "v 0.0"], ["f1", "v 0.1"]]],
|
|
76
|
+
["r1", [["f0", "v 1.0"]]]],
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Moxml::StructPlan: the fully compiled grammar — struct slots
|
|
6
|
+
# declared once, minted in C on leptris (1.9.197.1+) and via the
|
|
7
|
+
# plan fallback everywhere else. Results must be identical to
|
|
8
|
+
# Moxml::Plan.
|
|
9
|
+
SRecord = Struct.new(:id, :kind, :fields)
|
|
10
|
+
SField = Struct.new(:name, :unit, :value)
|
|
11
|
+
|
|
12
|
+
RSpec.describe Moxml::StructPlan do
|
|
13
|
+
let(:xml) do
|
|
14
|
+
'<catalog><record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record><record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record></catalog>'
|
|
15
|
+
end
|
|
16
|
+
let(:plan) do
|
|
17
|
+
described_class.new do
|
|
18
|
+
element "record", SRecord,
|
|
19
|
+
attrs: { "id" => :id, "kind" => :kind }, children: :fields
|
|
20
|
+
element "field", SField,
|
|
21
|
+
attrs: { "name" => :name, "unit" => :unit }, text: :value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
let(:block_plan) do
|
|
25
|
+
Moxml::Plan.new do
|
|
26
|
+
on("record") { |attrs, _t, fields| SRecord.new(attrs["id"], attrs["kind"], fields) }
|
|
27
|
+
on("field") { |attrs, text, _k| SField.new(attrs["name"], attrs["unit"], text) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
32
|
+
|
|
33
|
+
def with_adapter(name)
|
|
34
|
+
yield Moxml.new(name)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
skip "#{name} unavailable"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
ADAPTERS.each do |adapter_name|
|
|
40
|
+
describe "on #{adapter_name}" do
|
|
41
|
+
it "materializes the same structs as Moxml::Plan" do
|
|
42
|
+
with_adapter(adapter_name) do |ctx|
|
|
43
|
+
expect(plan.parse(xml, ctx))
|
|
44
|
+
.to eq(block_plan.parse(xml, ctx))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "treats unmatched elements as barriers like Moxml::Plan" do
|
|
51
|
+
barrier_xml = "<catalog><wrap><record id=\"r9\"/></wrap><record id=\"r8\"/></catalog>"
|
|
52
|
+
with_adapter(:leptris) do |ctx|
|
|
53
|
+
rows = plan.parse(barrier_xml, ctx)
|
|
54
|
+
expect(rows.map(&:id)).to eq(%w[r9 r8])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "runs the C executor on leptris (not the fallback)" do
|
|
59
|
+
with_adapter(:leptris) do |ctx|
|
|
60
|
+
doc = ctx.parse(xml)
|
|
61
|
+
roots = Moxml::Adapter::Leptris.plan_structs(
|
|
62
|
+
doc.native,
|
|
63
|
+
"record" => [SRecord, { "id" => :id, "kind" => :kind }, nil, :fields],
|
|
64
|
+
"field" => [SField, { "name" => :name, "unit" => :unit }, :value, nil],
|
|
65
|
+
)
|
|
66
|
+
expect(roots).to eq(plan.parse(xml, ctx))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moxml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.63
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- README.adoc
|
|
40
40
|
- Rakefile
|
|
41
41
|
- benchmark/pipeline_bench.rb
|
|
42
|
+
- benchmark/plan_bench.rb
|
|
42
43
|
- benchmarks/.gitignore
|
|
43
44
|
- benchmarks/generate_report.rb
|
|
44
45
|
- bin/console
|
|
@@ -270,6 +271,7 @@ files:
|
|
|
270
271
|
- lib/moxml/signature/transform_pipeline.rb
|
|
271
272
|
- lib/moxml/signature/verification_result.rb
|
|
272
273
|
- lib/moxml/signature/verifier.rb
|
|
274
|
+
- lib/moxml/struct_plan.rb
|
|
273
275
|
- lib/moxml/text.rb
|
|
274
276
|
- lib/moxml/version.rb
|
|
275
277
|
- lib/moxml/wrappers.rb
|
|
@@ -488,6 +490,7 @@ files:
|
|
|
488
490
|
- spec/moxml/opal_oga_smoke_spec.rb
|
|
489
491
|
- spec/moxml/opal_rexml_adapter_spec.rb
|
|
490
492
|
- spec/moxml/opal_smoke_spec.rb
|
|
493
|
+
- spec/moxml/plan_conformance_spec.rb
|
|
491
494
|
- spec/moxml/plan_spec.rb
|
|
492
495
|
- spec/moxml/processing_instruction_spec.rb
|
|
493
496
|
- spec/moxml/readonly_parse_spec.rb
|
|
@@ -508,6 +511,7 @@ files:
|
|
|
508
511
|
- spec/moxml/signature/key_extractor_spec.rb
|
|
509
512
|
- spec/moxml/signature/model_spec.rb
|
|
510
513
|
- spec/moxml/signature/round_trip_spec.rb
|
|
514
|
+
- spec/moxml/struct_plan_spec.rb
|
|
511
515
|
- spec/moxml/text_spec.rb
|
|
512
516
|
- spec/moxml/version_spec.rb
|
|
513
517
|
- spec/moxml/xml_emitter_spec.rb
|