moxml 0.5.32 → 0.5.33

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: 627111c496efca4687eabeeb78989d611d3d308adc5ed32443c7058da51c57c9
4
- data.tar.gz: 86d1cc6c471d07084e0d2e0de9412b286b5486b202f9fe677b7417f514c1fe55
3
+ metadata.gz: 6fcb33efd8969194833ce7ee6b7e3e3bfa6517ec7d216028407d28fbc95b1641
4
+ data.tar.gz: ec2239b9b5501b208331ce5874764ad99cad357bacec08af6cc47d1f3aa69aff
5
5
  SHA512:
6
- metadata.gz: f1108c98dc8af92913605dd08b84eb50bbfec92a5dd286e15656c3ecd57937ce56bc186c9393a740f6bd8a8316f06fb059db226f20ffc9f0b84cfb433e1a532d
7
- data.tar.gz: 02d109a32659b30fc34ac5d6d3f01bc0d32d9e1ca6a38cfdc2fe3880a02c779ce5ff41fa2f0eda04adc2a9ccd6702f372d29b4d2a6fe33faeb0456cb8852d26b
6
+ metadata.gz: 18526fb6430f0231b187e888104c4f2fef7e38999d1015d8324ea53b90339de4c0d5d58cb4b591380b5d6cca704014fcb30ca5429f5abeff2a027217e1d47e54
7
+ data.tar.gz: 206cfe96f4bcac080a134092dac6f305d931d9d589c0dd544cf47e6bceb39d77d023f3b6e6c80deaeba94d10997840c90b583770d8c9d35e81dca65706354c0c
data/Rakefile CHANGED
@@ -244,6 +244,14 @@ namespace :benchmark do
244
244
  ENV.delete("SKIP_BENCHMARKS")
245
245
  sh "bundle exec rspec spec/performance/xpath_benchmark_spec.rb"
246
246
  end
247
+ # Consumer-pipeline leg (issue #198): nested document walked into
248
+ # typed objects — median time + GC.stat allocation deltas, where
249
+ # engine-level numbers hide wrapper overhead. PIPELINE_ADAPTER and
250
+ # PIPELINE_REPS env-overridable.
251
+ desc "Consumer-pipeline benchmark (typed-object walk, issue #198)"
252
+ task :pipeline do
253
+ sh "ruby", "benchmark/pipeline_bench.rb"
254
+ end
247
255
 
248
256
  desc "Generate adapter benchmark report"
249
257
  task :report do
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Consumer-pipeline benchmark leg (issue #198): measures a nested
4
+ # document walked into typed objects — the shape consumers actually
5
+ # run, where engine-level numbers hide wrapper overhead (Amdahl).
6
+ # Reports fresh-run medians with GC.stat allocation deltas.
7
+ require "moxml"
8
+ require "nokogiri"
9
+
10
+ ADAPTER = (ENV["PIPELINE_ADAPTER"] || "leptris").to_sym
11
+ REPS = (ENV["PIPELINE_REPS"] || 7).to_i
12
+
13
+ parts = [%(<?xml version="1.0"?><catalog>)]
14
+ 150.times do |i|
15
+ parts << %(<record id="r#{i}" kind="k#{i % 3}">)
16
+ 6.times { |j| parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>) }
17
+ parts << %(</record>)
18
+ end
19
+ parts << %(</catalog>)
20
+ XML = parts.join
21
+
22
+ Field = Struct.new(:name, :unit, :value)
23
+ Record = Struct.new(:id, :kind, :fields)
24
+ Catalog = Struct.new(:records)
25
+
26
+ def walk_document(adapter_name)
27
+ ctx = Moxml.new(adapter_name)
28
+ doc = ctx.parse(XML)
29
+ catalog = Catalog.new([])
30
+ doc.root.children.each do |rec|
31
+ next unless rec.is_a?(Moxml::Element)
32
+
33
+ record = Record.new(rec["id"], rec["kind"], [])
34
+ rec.children.each do |f|
35
+ next unless f.is_a?(Moxml::Element)
36
+
37
+ record.fields << Field.new(f["name"], f["unit"], f.text)
38
+ end
39
+ catalog.records << record
40
+ end
41
+ raise "shape" unless catalog.records.size == 150
42
+
43
+ raise "content" unless catalog.records[7].fields[3].value.include?("7.3")
44
+
45
+ catalog
46
+ end
47
+
48
+ def measure(adapter_name)
49
+ walk_document(adapter_name) # warm: autoloads off the books
50
+ times = []
51
+ allocs = nil
52
+ REPS.times do
53
+ GC.start
54
+ a0 = GC.stat(:total_allocated_objects)
55
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ walk_document(adapter_name)
57
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
+ a1 = GC.stat(:total_allocated_objects)
59
+ times << ((t1 - t0) * 1e6)
60
+ allocs = a1 - a0
61
+ end
62
+ median = times.sort[times.size / 2]
63
+ [median, allocs]
64
+ end
65
+
66
+ noko_med, noko_alloc = measure(:nokogiri)
67
+ puts format("pipeline nokogiri: median %<med>7.0fµs %<allocs>6d allocs", med: noko_med, allocs: noko_alloc)
68
+ target_med, target_alloc = measure(ADAPTER)
69
+ puts format("pipeline %<adapter>-8s: median %<med>7.0fµs %<allocs>6d allocs (%<ratio>.2fx, allocs %<aratio>.2fx)",
70
+ adapter: ADAPTER, med: target_med, allocs: target_alloc,
71
+ ratio: noko_med / target_med, aratio: noko_alloc.to_f / target_alloc)
72
+ puts format("(one-shot shape: parse + typed walk + content asserts; YJIT=%<yjit>s; load %<load>.1f)",
73
+ yjit: ENV["RUBY_YJIT_ENABLE"] ? "on" : "off",
74
+ load: `sysctl -n vm.loadavg`.split[1].to_f)
@@ -91,6 +91,11 @@ the context's wrapper identity map opts out.
91
91
  Whether the subtree can contain entity markers; gates the
92
92
  post-serialize restore scan.
93
93
 
94
+ `digest(node, drop_ws_text:)`::
95
+ The backend's content-defined subtree Merkle hash where one exists
96
+ (leptris >= 1.9.99, engine #869); nil everywhere else. `Node#digest`
97
+ gates on nil — consumers fall back to walking.
98
+
94
99
  `bulk_materialize?`::
95
100
  Whether the adapter offers a bulk path for `Moxml::Materializer`;
96
101
  `materialize_fields` fills the reused flat buffers and yields the
@@ -114,6 +114,32 @@ node.namespace? # Is it a namespace?
114
114
  node.document # Get owning document
115
115
  ----
116
116
 
117
+ == Subtree digest
118
+
119
+ `Node#digest` exposes the backend's content-defined Merkle hash of a
120
+ subtree when one exists — currently only the leptris adapter
121
+ (libleptris >= 1.9.99, leptris#869):
122
+
123
+ [source,ruby]
124
+ ----
125
+ node.digest # => Integer (u64) or nil
126
+ node.digest(drop_ws_text: true) # skip whitespace-only text nodes
127
+ ----
128
+
129
+ Contract:
130
+
131
+ * `nil` means "no digest support" (every other adapter) — consumers
132
+ gate on nil and fall back to walking.
133
+ * Equal digests imply subtree equivalence under the flag set;
134
+ unequal digests imply nothing (descend and compare).
135
+ * The hash covers element prefix + resolved namespace URI + local
136
+ name, attributes sorted by (URI, local) and first-wins
137
+ deduplicated, and children in document order. No addresses
138
+ participate, so identical trees hash equal across parses and
139
+ processes.
140
+ * Nodes without a backing engine node (documents, attributes,
141
+ synthetic declarations) answer nil.
142
+
117
143
 
118
144
  See also:
119
145
 
@@ -106,6 +106,26 @@ load-immune — use them when the machine is busy:*
106
106
  hundreds of iterations — spot checks of 30 have missed 5-30% races
107
107
  on this codebase.
108
108
 
109
+ *More traps, paid for in this codebase:*
110
+
111
+ - Optional keyword arguments do NOT allocate on Ruby 3.4 (measured
112
+ 0.000 allocs/call with one and two optional kwargs) — do not
113
+ "optimize" them into positional arguments expecting an allocation
114
+ win. An earlier version of this page claimed otherwise: a +601
115
+ allocation delta attributed to kwargs was actually the write-path
116
+ cache clears allocating a fresh Hash per write (+500), bundled
117
+ into the same change as a kwargs edit. The general lesson:
118
+ attribute allocation deltas per-change, never per-bundle.
119
+ - Engines free attribute natives on removal — reading any native
120
+ property after `remove_attribute_native` is a use-after-free.
121
+ The crash only surfaced mid-suite under GC churn (standalone
122
+ repros passed); read names BEFORE the removal call.
123
+ - Scope invalidation to what the mutation can affect: a
124
+ non-xmlns attribute write cannot change namespace scope, so it
125
+ must not bump the document-wide scope generation (which evicts
126
+ every wrapper's caches). +5-8% on write-heavy mixed workloads,
127
+ and bulk builds stop thrashing every reader's cache.
128
+
109
129
  === leptris 1.9.105 numbers (reference table)
110
130
 
111
131
  XML parse 2.4-2.8x, document serialize 2.4x, element queries 32x,
@@ -198,6 +218,29 @@ first access, not at construction — `.size`/`.empty?`/`.first` on a
198
218
  (that path also skips the binding's per-node wrapper materialization
199
219
  entirely; see `LazyNodeSet` under the leptris adapter).
200
220
 
221
+ === Mint priming and scoped invalidation
222
+
223
+ `Node.wrap` resolves the adapter and node type once and primes both
224
+ onto the wrapper (positional constructor arguments) — a fresh
225
+ wrapper previously re-paid the context hop and the type probe on
226
+ its first access. Attribute writes that cannot change namespace
227
+ scope (bare and non-`xmlns` prefixed names) invalidate the
228
+ element's caches locally instead of bumping the document-wide scope
229
+ generation; `xmlns` writes keep the global bump.
230
+
231
+ | path | before | after |
232
+ | Attribute write (`[]=`) | 822ns | 758ns (-8%) |
233
+ | Cold walk (parse + wrapper mint, 752 nodes) | 811µs | 699µs (-14%) |
234
+ | Mixed build+read (nokogiri) | 1355µs / 5267 allocs | 1283µs / 5267 allocs |
235
+ | `Element#text` read | 422ns | 346ns (-18%) |
236
+ | `create_text` allocations | 6/node | 5/node |
237
+ | Bare attribute read `[]` | 714ns | 513ns (-28%) |
238
+
239
+ Allocations unchanged (the invalidation clears are nil-writes; the
240
+ read cache materializes lazily). The remaining cold-walk gap is
241
+ binding-side (its per-node Ruby wrapper construction) — tracked
242
+ upstream as the TypedData work.
243
+
201
244
  === Prefer bulk paths for per-node conversion
202
245
 
203
246
  Per-node Ruby iteration pays the wrapper tax per element. When the
@@ -28,6 +28,24 @@ module Moxml
28
28
  )
29
29
  end
30
30
 
31
+ # Uniform fragment parsing: returns the fragment's top-level
32
+ # nodes as native objects. Engines without a fragment node
33
+ # type (everything but Nokogiri) get the wrapper-parse
34
+ # shape — the same trick Element#append_xml and #inner_xml=
35
+ # use — with a synthetic root whose children are the
36
+ # fragment's top-level nodes.
37
+ def root(_document)
38
+ raise Moxml::NotImplementedError.new(
39
+ "root not implemented", feature: "root", adapter: name
40
+ )
41
+ end
42
+
43
+ def parse_fragment(xml, _context = nil)
44
+ doc = parse("<m>#{xml}</m>")
45
+ root = root(doc)
46
+ root ? children(root) : []
47
+ end
48
+
31
49
  # Streaming incremental parse (leptris engine): yields each
32
50
  # completed element while the parse runs, releasing prior
33
51
  # subtrees — memory bounded by the largest subtree, not the
@@ -98,14 +116,19 @@ module Moxml
98
116
  )
99
117
  end
100
118
 
119
+ # Backends without a native subtree digest answer nil —
120
+ # the wrapper contract Node#digest gates on (issue #173).
121
+ def digest(*)
122
+ nil
123
+ end
124
+
101
125
  def create_element(name, owner_doc: nil)
102
126
  validate_element_name(name)
103
127
  create_native_element(name, owner_doc)
104
128
  end
105
129
 
106
130
  def create_text(content, owner_doc: nil)
107
- # Ox freezes the content, so we need to dup it
108
- create_native_text(normalize_xml_value(content).dup, owner_doc)
131
+ create_native_text(normalize_xml_value(content), owner_doc)
109
132
  end
110
133
 
111
134
  def create_cdata(content, owner_doc: nil)
@@ -34,6 +34,12 @@ module Moxml
34
34
  ATTR_RESULT_NATIVE =
35
35
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.105")
36
36
 
37
+ # leptris_node_digest shipped in bindings 1.9.99 (libleptris
38
+ # 1.9.99, engine #869): on-demand Merkle subtree hash, zero
39
+ # cost when unused. Below it, Node#digest answers nil.
40
+ DIGEST_SUPPORTED =
41
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.99")
42
+
37
43
  # Native C14N delegation probe: the engine's C14N must
38
44
  # byte-match the Ruby reference (ported from canon) on a
39
45
  # namespace-sorting + attributes + comments shape before
@@ -180,13 +186,12 @@ module Moxml
180
186
  # restoration when the document carries them (entity-free
181
187
  # documents — the common case — skip the scan; parentless
182
188
  # iterparse elements are never bearing).
189
+ # Raw qualified-name read; the entity-restore decision is
190
+ # the wrapper's (Element#[] rides its generation memo — the
191
+ # document-plus-attachment probe here cost more than the
192
+ # read itself).
183
193
  def bare_attr_value(element, name)
184
- value = element[name.to_s]
185
- if value.is_a?(String) && entity_bearing?(element)
186
- restore_entities(value)
187
- else
188
- value
189
- end
194
+ element[name.to_s]
190
195
  end
191
196
 
192
197
  def native_identity_stable?
@@ -254,6 +259,15 @@ module Moxml
254
259
  # elements as the parse runs; each prior subtree is released.
255
260
  # Yielded elements are parentless (document nil) and valid
256
261
  # only inside the block — the wrapper mirrors that lifetime.
262
+ # Wrapper-parse: the synthetic root's children are the
263
+ # fragment's top-level nodes (the append_xml / inner_xml=
264
+ # trick). Children come through the normal children path so
265
+ # entity-marker splitting applies.
266
+ def parse_fragment(xml, _context = nil)
267
+ doc = parse("<m>#{xml}</m>")
268
+ children(doc.native.root)
269
+ end
270
+
257
271
  def iterparse(xml, mode = :top_level, _context = nil, &block)
258
272
  raise ArgumentError, "iterparse requires a block" unless block
259
273
 
@@ -537,6 +551,18 @@ module Moxml
537
551
  end
538
552
  end
539
553
 
554
+ # Subtree digest over the binding node (issue #173). Only
555
+ # binding Nodes carry a C node handle: the synthetic
556
+ # wrappers (declarations, doctypes, entity markers) and the
557
+ # lightweight Attr triples answer nil.
558
+ def digest(node, drop_ws_text: false)
559
+ return nil unless DIGEST_SUPPORTED
560
+ return nil unless node.is_a?(::Leptris::XML::Node) &&
561
+ !node.is_a?(::Leptris::XML::ResultAttr)
562
+
563
+ node.digest(drop_ws: drop_ws_text)
564
+ end
565
+
540
566
  def node_name(node)
541
567
  return node.root_name if node.is_a?(::Leptris::XML::DocType)
542
568
  return node.target if node.is_a?(CustomizedLeptris::DocumentPI)
@@ -567,19 +593,10 @@ module Moxml
567
593
  end
568
594
 
569
595
  def children(node)
596
+ # Frequency-ordered: elements dominate every walk and paid
597
+ # ten failed compares to reach the else arm.
570
598
  case node
571
- when ::Leptris::XML::Document
572
- assemble_document_children(node)
573
- when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
574
- CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
575
- CustomizedLeptris::DocumentPI,
576
- # Terminal node kinds pay an FFI round trip for an empty
577
- # list; unfiltered recursions visit every text node.
578
- ::Leptris::XML::Text, ::Leptris::XML::Comment,
579
- ::Leptris::XML::CDATA, ::Leptris::XML::ProcessingInstruction,
580
- ::Leptris::XML::Attr
581
- []
582
- else
599
+ when ::Leptris::XML::Element
583
600
  natives = node.children.to_a
584
601
  # Parse records whether the preprocessed source held any
585
602
  # entity markers, and the ER builder path flips the flag
@@ -592,6 +609,19 @@ module Moxml
592
609
  attachments.get(node.document, :entity_markers) == false
593
610
 
594
611
  split_entity_markers(natives, node)
612
+ when ::Leptris::XML::Document
613
+ assemble_document_children(node)
614
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
615
+ CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
616
+ CustomizedLeptris::DocumentPI,
617
+ # Terminal node kinds pay an FFI round trip for an empty
618
+ # list; unfiltered recursions visit every text node.
619
+ ::Leptris::XML::Text, ::Leptris::XML::Comment,
620
+ ::Leptris::XML::CDATA, ::Leptris::XML::ProcessingInstruction,
621
+ ::Leptris::XML::Attr
622
+ []
623
+ else
624
+ node.children.to_a
595
625
  end
596
626
  end
597
627
 
@@ -766,13 +796,18 @@ module Moxml
766
796
  end
767
797
 
768
798
  def text_content(node)
799
+ # Frequency-ordered: elements dominate reads; they paid
800
+ # three failed compares to reach the else arm. The
801
+ # duplicated branch bodies are the point.
769
802
  case node
803
+ when ::Leptris::XML::Element, ::Leptris::XML::Text
804
+ node.content.to_s
770
805
  when ::Leptris::XML::Document then node.root ? node.root.content : ""
771
806
  when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
772
807
  CustomizedLeptris::EntityReference
773
808
  ""
774
809
  when CustomizedLeptris::TextSegment then node.content
775
- else node.content.to_s
810
+ else node.content.to_s # rubocop:disable Lint/DuplicateBranch
776
811
  end
777
812
  end
778
813
 
@@ -23,27 +23,26 @@ module Moxml
23
23
  true
24
24
  end
25
25
 
26
+ # Nokogiri has a real fragment node type — its children ARE
27
+ # the fragment's top-level nodes.
28
+ def parse_fragment(xml, _context = nil)
29
+ processed = Entity.preprocess_entities(xml)
30
+ ::Nokogiri::XML::DocumentFragment.parse(processed) do |config|
31
+ config.strict.nonet
32
+ config.recover
33
+ end.children.to_a
34
+ end
35
+
26
36
  # Fast bare-name read for Element#[]: the native call plus,
27
37
  # only when the parse recorded entity markers, their
28
38
  # restoration (the resolver path's other real semantic).
29
39
  # The marker flag is constant per document — a WeakMap on
30
40
  # the adapter beats the per-read document fetch + attachment
31
41
  # hash chain.
32
- def doc_entity_markers?(doc)
33
- cache = (@doc_markers ||= ObjectSpace::WeakMap.new)
34
- cached = cache[doc]
35
- return cached unless cached.nil?
36
-
37
- cache[doc] = attachments.get(doc, :entity_markers) == true
38
- end
39
-
42
+ # Raw qualified-name read; the entity-restore decision is
43
+ # the wrapper's (Element#[] rides its generation memo).
40
44
  def bare_attr_value(element, name)
41
- value = element[name.to_s]
42
- if value.is_a?(String) && doc_entity_markers?(element.document)
43
- restore_entities(value)
44
- else
45
- value
46
- end
45
+ element[name.to_s]
47
46
  end
48
47
 
49
48
  def native_identity_stable?
@@ -307,6 +307,11 @@ module Moxml
307
307
  current
308
308
  end
309
309
 
310
+ def parse_fragment(xml, _context = nil)
311
+ doc = parse("<m>#{xml}</m>").native
312
+ children(root(doc))
313
+ end
314
+
310
315
  def root(document)
311
316
  document.children.find { |node| node.is_a?(::Oga::XML::Element) }
312
317
  end
@@ -94,8 +94,11 @@ module Moxml
94
94
  ::Ox::Element.new(name)
95
95
  end
96
96
 
97
+ # Ox stores the Ruby string object itself as the node value —
98
+ # without the copy, later caller mutations would write through
99
+ # into the document (ownership shield; see set_text_content).
97
100
  def create_native_text(content, _owner_doc = nil)
98
- content
101
+ content.dup
99
102
  end
100
103
 
101
104
  def create_native_entity_reference(name)
@@ -330,6 +333,12 @@ module Moxml
330
333
  current
331
334
  end
332
335
 
336
+ def parse_fragment(xml, _context = nil)
337
+ doc = parse("<m>#{xml}</m>").native
338
+ synthetic = doc.nodes.find { |node| node.is_a?(::Ox::Element) }
339
+ children(synthetic)
340
+ end
341
+
333
342
  def root(document)
334
343
  document.nodes&.find { |node| node.is_a?(::Ox::Element) }
335
344
  end
@@ -357,7 +366,14 @@ module Moxml
357
366
  # Ox converts all values to strings
358
367
  remove_attribute(element, name)
359
368
  else
360
- element.attributes[name.to_s] = value
369
+ key = name.to_s
370
+ attrs = element.attributes
371
+ # Ox parses attributes under Symbol keys; a naive
372
+ # string-keyed write would ADD a second entry instead
373
+ # of replacing (the parity suite caught reads returning
374
+ # the stale symbol-keyed value after a write).
375
+ attrs.delete(key.to_sym)
376
+ attrs[key] = value
361
377
  end
362
378
 
363
379
  ::Moxml::Adapter::CustomizedOx::Attribute.new(
@@ -569,9 +585,11 @@ module Moxml
569
585
  end
570
586
 
571
587
  def set_text_content(node, content)
588
+ # Copy on the same ownership contract as create_native_text
589
+ content = content.to_s.dup
572
590
  case node
573
- when String then node.replace(content.to_s)
574
- when ::Ox::Element then node.replace_text(content.to_s)
591
+ when String then node.replace(content)
592
+ when ::Ox::Element then node.replace_text(content)
575
593
  else
576
594
  node.value = content.to_s
577
595
  end
@@ -602,7 +620,11 @@ module Moxml
602
620
  end
603
621
 
604
622
  def namespace_prefix(namespace)
605
- namespace.prefix
623
+ # Ox spells the default namespace's prefix "xmlns"; the
624
+ # wrapper contract is nil there (parity with the other
625
+ # adapters' in-scope maps).
626
+ prefix = namespace.prefix
627
+ prefix == "xmlns" ? nil : prefix
606
628
  end
607
629
 
608
630
  def namespace_uri(namespace)
@@ -258,6 +258,11 @@ module Moxml
258
258
  node.document
259
259
  end
260
260
 
261
+ def parse_fragment(xml, _context = nil)
262
+ doc = parse("<m>#{xml}</m>").native
263
+ children(root(doc))
264
+ end
265
+
261
266
  def root(document)
262
267
  document.root
263
268
  end
@@ -2,8 +2,11 @@
2
2
 
3
3
  module Moxml
4
4
  class Attribute < Node
5
+ # Immutable between name= writes (which clear it via
6
+ # clear_native_memo!), unlike element names that can change via
7
+ # native adoption; the memo removes an adapter read per access.
5
8
  def name
6
- adapter.attribute_name(@native)
9
+ @name ||= adapter.attribute_name(@native)
7
10
  end
8
11
 
9
12
  def name=(new_name)
@@ -11,6 +14,7 @@ module Moxml
11
14
  # to keep tracking — same object for in-place adapters, fresh
12
15
  # object for value-object adapters (leptris).
13
16
  context.bump_namespace_scope_generation
17
+ @name = nil
14
18
  @native = adapter.set_attribute_name(@native, new_name)
15
19
  end
16
20
 
@@ -22,7 +26,16 @@ module Moxml
22
26
 
23
27
  def value
24
28
  val = @native.value.to_s
25
- adapter.restore_entities(val)
29
+ # Same guard as Element#text: entity-free documents skip the
30
+ # marker restore scans. The memo rides the owning element's
31
+ # (attr natives have no entity probe); a detached attribute
32
+ # wrapper falls back to the unconditional restore.
33
+ parent = @parent_node
34
+ if parent.nil? || parent.entity_bearing?
35
+ adapter.restore_entities(val)
36
+ else
37
+ val
38
+ end
26
39
  end
27
40
 
28
41
  alias content value
@@ -33,7 +46,13 @@ module Moxml
33
46
  end
34
47
 
35
48
  def value=(new_value)
36
- context.bump_namespace_scope_generation
49
+ name = self.name
50
+ if name == "xmlns" || name.start_with?("xmlns:")
51
+ # Declaration rewrite — namespace scope changed
52
+ context.bump_namespace_scope_generation
53
+ else
54
+ @parent_node&.invalidate_attribute_value_cache!
55
+ end
37
56
  adapter.set_attribute_value(@native, new_value)
38
57
  end
39
58
 
@@ -59,9 +78,17 @@ module Moxml
59
78
  end
60
79
 
61
80
  def remove
81
+ # The name must be read before the removal — engines free the
82
+ # attribute native, and post-removal reads are use-after-free.
83
+ name = self.name
84
+ declaration = name == "xmlns" || name.start_with?("xmlns:")
62
85
  adapter.remove_attribute_native(@native)
63
86
  if @parent_node.is_a?(Moxml::Element)
64
- @parent_node.invalidate_attribute_cache!
87
+ if declaration
88
+ @parent_node.invalidate_attribute_cache!
89
+ else
90
+ @parent_node.invalidate_local_attribute_cache!
91
+ end
65
92
  end
66
93
  self
67
94
  end
@@ -41,7 +41,7 @@ module Moxml
41
41
  uri = prefix_uri(element, prefix)
42
42
  return nil if uri.nil?
43
43
 
44
- adapter = element.context.config.adapter
44
+ adapter = element.adapter
45
45
  if adapter.is_a?(Moxml::Adapter::Leptris)
46
46
  return adapter.expanded_attr_value(element.native, uri, local)
47
47
  end
@@ -105,7 +105,7 @@ module Moxml
105
105
  # @return [String] the assigned value
106
106
  def assign(element, name, value)
107
107
  name = name.to_s
108
- adapter = element.context.config.adapter
108
+ adapter = element.adapter
109
109
  if name == "xmlns" || name.start_with?("xmlns:")
110
110
  adapter.set_attribute(element.native, name, value)
111
111
  element.invalidate_attribute_cache!
@@ -122,7 +122,7 @@ module Moxml
122
122
  # writes.
123
123
  if !name.include?(":") && adapter.bare_set_qname_safe?
124
124
  adapter.set_attribute(element.native, name, value)
125
- element.invalidate_attribute_cache!
125
+ element.invalidate_local_attribute_cache!
126
126
  return value
127
127
  end
128
128
 
@@ -133,7 +133,7 @@ module Moxml
133
133
  end
134
134
 
135
135
  adapter.set_attribute(element.native, name, value)
136
- element.invalidate_attribute_cache!
136
+ element.invalidate_local_attribute_cache!
137
137
  value
138
138
  end
139
139
 
@@ -143,7 +143,7 @@ module Moxml
143
143
  # @return [Moxml::Attribute, nil] the removed attribute
144
144
  def remove(element, name)
145
145
  name = name.to_s
146
- adapter = element.context.config.adapter
146
+ adapter = element.adapter
147
147
  if name == "xmlns" || name.start_with?("xmlns:")
148
148
  adapter.remove_attribute(element.native, name)
149
149
  element.invalidate_attribute_cache!
@@ -154,7 +154,7 @@ module Moxml
154
154
  return nil unless attr
155
155
 
156
156
  adapter.remove_attribute_native(attr.native)
157
- element.invalidate_attribute_cache!
157
+ element.invalidate_local_attribute_cache!
158
158
  attr
159
159
  end
160
160
 
@@ -139,7 +139,7 @@ module Moxml
139
139
 
140
140
  def bindings_declared_on(element)
141
141
  bindings = {}
142
- element.namespaces.each do |ns|
142
+ element.namespace_definitions.each do |ns|
143
143
  prefix = ns.prefix
144
144
  prefix = "" if prefix.nil? || prefix == "xmlns"
145
145
  bindings[prefix] = ns.uri
data/lib/moxml/context.rb CHANGED
@@ -106,6 +106,18 @@ module Moxml
106
106
  # Memory stays bounded by the largest subtree, not the document
107
107
  # (iterparse_file streams the file C-side). Yielded elements are
108
108
  # parentless and valid only inside the block.
109
+ # Parse an XML fragment: returns its top-level nodes as an
110
+ # Array of wrappers, uniformly across adapters (issue #188 —
111
+ # fragment: true was Nokogiri-only). Engines without a fragment
112
+ # node type parse inside a synthetic root; the fragment's nodes
113
+ # are the result either way.
114
+ def parse_fragment(xml)
115
+ adapter = config.adapter
116
+ adapter.parse_fragment(xml, self).map do |native|
117
+ Moxml::Node.wrap(native, self)
118
+ end
119
+ end
120
+
109
121
  def iterparse(xml, mode: :top_level, &block)
110
122
  config.adapter.iterparse(xml, mode, self, &block)
111
123
  end
@@ -4,7 +4,7 @@ module Moxml
4
4
  class Document < Node
5
5
  attr_accessor :has_xml_declaration
6
6
 
7
- def initialize(native, context)
7
+ def initialize(native, context, adapter = nil, node_type = nil)
8
8
  super
9
9
  @has_xml_declaration = false
10
10
  end
data/lib/moxml/element.rb CHANGED
@@ -63,7 +63,16 @@ module Moxml
63
63
  # names, where resolution is real work.
64
64
  adapter = self.adapter
65
65
  if !key.include?(":") && adapter.bare_get_qname_safe?
66
- return adapter.bare_attr_value(@native, key)
66
+ # The entity-restore decision rides the wrapper's generation
67
+ # memo (as Element#text) — the adapter-level probe walks to
68
+ # the document and the attachment store on every read, which
69
+ # dominated the fast path.
70
+ value = adapter.bare_attr_value(@native, key)
71
+ if value.is_a?(String) && entity_bearing?
72
+ return adapter.restore_entities(value)
73
+ end
74
+
75
+ value
67
76
  end
68
77
 
69
78
  cache = attribute_read_cache
@@ -86,7 +95,11 @@ module Moxml
86
95
  # every attribute or namespace mutation anywhere bumps it.
87
96
  def attribute_read_cache
88
97
  generation = context.namespace_scope_generation
89
- if @attribute_cache_generation != generation
98
+ # @attribute_cache.nil? covers the local invalidation clears —
99
+ # a value or list change without a scope bump (see
100
+ # invalidate_local_attribute_cache!) — so writes never allocate
101
+ # a replacement hash; the next read materializes one lazily.
102
+ if @attribute_cache.nil? || @attribute_cache_generation != generation
90
103
  @attribute_cache = {}
91
104
  @attribute_cache_generation = generation
92
105
  end
@@ -180,12 +193,25 @@ module Moxml
180
193
  invalidate_namespace_cache!
181
194
  end
182
195
 
196
+ # All namespaces IN SCOPE for this element — its own
197
+ # declarations plus everything inherited from ancestors —
198
+ # matching the Nokogiri #namespaces contract consumers port
199
+ # against (issue #198: this returned only own declarations,
200
+ # losing ancestor scope under every backend).
183
201
  def namespaces
184
- @namespaces ||= adapter.namespace_definitions(@native).map do |ns|
202
+ in_scope_namespaces
203
+ end
204
+
205
+ # The element's OWN namespace declarations only (not
206
+ # inherited). C14n's visibly-utilized calculation and the
207
+ # materializer's declaration pairs want exactly this shape.
208
+ def namespace_definitions
209
+ return @namespace_definitions unless @namespace_definitions.nil?
210
+
211
+ @namespace_definitions = adapter.namespace_definitions(@native).map do |ns|
185
212
  Namespace.new(ns, context)
186
213
  end
187
214
  end
188
- alias namespace_definitions namespaces
189
215
 
190
216
  # The element's OWN namespace declarations as [prefix, uri]
191
217
  # pairs (nil prefix = default namespace) — not the inherited
@@ -217,7 +243,10 @@ module Moxml
217
243
 
218
244
  def text
219
245
  val = adapter.text_content(@native)
220
- adapter.restore_entities(val)
246
+ # Entity-free documents (the common case) skip the marker
247
+ # restore scans entirely — the per-wrapper entity_bearing?
248
+ # memo rides the adapter's serialize generation.
249
+ entity_bearing? ? adapter.restore_entities(val) : val
221
250
  end
222
251
 
223
252
  alias content text
@@ -229,7 +258,7 @@ module Moxml
229
258
 
230
259
  def inner_text
231
260
  text = raw_inner_text
232
- adapter.restore_entities(text)
261
+ entity_bearing? ? adapter.restore_entities(text) : text
233
262
  end
234
263
 
235
264
  # Returns inner text without entity marker restoration.
@@ -291,9 +320,25 @@ module Moxml
291
320
  children
292
321
  end
293
322
 
294
- # Called by Attribute#remove and the attribute mutators. Clears
295
- # the attribute list and the resolved-read cache, and bumps the
296
- # context generation so cross-wrapper reads recompute.
323
+ # Attribute mutations that cannot change namespace scope (bare
324
+ # and non-xmlns prefixed names) invalidate locally: the
325
+ # resolved-read cache alone for value writes, the wrapper list
326
+ # too when the attribute set changes. No context generation
327
+ # bump — that would evict every wrapper's caches document-wide
328
+ # on each write of a bulk build.
329
+ def invalidate_attribute_value_cache!
330
+ @attribute_cache = nil
331
+ end
332
+
333
+ def invalidate_local_attribute_cache!
334
+ @attributes = nil
335
+ @attribute_cache = nil
336
+ end
337
+
338
+ # Called by the namespace-scoped attribute paths (xmlns writes
339
+ # and removals) and Attribute#name=. Clears the attribute list
340
+ # and the resolved-read cache, and bumps the context generation
341
+ # so cross-wrapper reads recompute.
297
342
  def invalidate_attribute_cache!
298
343
  @attributes = nil
299
344
  @attribute_cache = nil
@@ -305,6 +350,7 @@ module Moxml
305
350
  # any children cache — recomputes on next read.
306
351
  def invalidate_namespace_cache!
307
352
  @namespaces = nil
353
+ @namespace_definitions = nil
308
354
  @in_scope_namespaces = nil
309
355
  context.bump_namespace_scope_generation
310
356
  end
data/lib/moxml/node.rb CHANGED
@@ -12,10 +12,16 @@ module Moxml
12
12
 
13
13
  attr_reader :native, :context
14
14
 
15
- def initialize(native, context)
15
+ # adapter/node_type are primed by Node.wrap, which resolves both
16
+ # before choosing the wrapper class — a fresh wrapper would
17
+ # otherwise pay the context hop and the type probe again on its
18
+ # first access.
19
+ def initialize(native, context, adapter = nil, node_type = nil)
16
20
  @context = context
17
21
  @native = native
18
22
  @parent_node = nil
23
+ @adapter = adapter
24
+ @node_type_cached = node_type
19
25
  end
20
26
 
21
27
  # Update native reference after identity-changing operations
@@ -349,6 +355,18 @@ module Moxml
349
355
  adapter.line_number(@native)
350
356
  end
351
357
 
358
+ # Content-defined Merkle digest of this subtree (issue #173,
359
+ # companion to leptris#869): a u64 Integer where the backend
360
+ # computes one, nil everywhere else. Consumers gate on nil and
361
+ # fall back to walking. Equal digests imply subtree equivalence
362
+ # under the flag set; unequal digests imply nothing (descend).
363
+ # +drop_ws_text+ skips whitespace-only text nodes.
364
+ #
365
+ # @return [Integer, nil]
366
+ def digest(drop_ws_text: false)
367
+ adapter.digest(@native, drop_ws_text: drop_ws_text)
368
+ end
369
+
352
370
  def outer_xml
353
371
  to_xml
354
372
  end
@@ -418,10 +436,12 @@ module Moxml
418
436
  cached = context.wrapper_for(node)
419
437
  return cached if cached
420
438
 
421
- type = adapter(context).node_type(node)
439
+ adapter = adapter(context)
440
+ type = adapter.node_type(node)
422
441
  klass = node_type_map[type] || self
423
442
 
424
- klass.new(node, context).tap { |wrapper| context.register_wrapper(node, wrapper) }
443
+ klass.new(node, context, adapter, type)
444
+ .tap { |wrapper| context.register_wrapper(node, wrapper) }
425
445
  end
426
446
 
427
447
  # Internal: Set the parent node for cache invalidation tracking.
@@ -429,14 +449,14 @@ module Moxml
429
449
  # relationships. Public to allow cross-class usage within Moxml internals.
430
450
  attr_writer :parent_node
431
451
 
432
- protected
433
-
434
452
  def adapter
435
453
  # A context's adapter object is fixed for its lifetime; the
436
454
  # chain deref ran on every node access.
437
455
  @adapter ||= context.config.adapter
438
456
  end
439
457
 
458
+ protected
459
+
440
460
  def self.adapter(context)
441
461
  context.config.adapter
442
462
  end
data/lib/moxml/text.rb CHANGED
@@ -4,7 +4,7 @@ module Moxml
4
4
  class Text < Node
5
5
  def content
6
6
  text = raw_content
7
- adapter.restore_entities(text)
7
+ entity_bearing? ? adapter.restore_entities(text) : text
8
8
  end
9
9
 
10
10
  # Returns raw content without entity marker restoration.
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.32"
4
+ VERSION = "0.5.33"
5
5
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cross-adapter contract parity (issue #198): every bundled adapter
4
+ # runs the same behavioral battery, so parity is CI-enforced rather
5
+ # than discovered by consumers.
6
+ %w[nokogiri leptris ox oga rexml].each do |adapter_name|
7
+ begin
8
+ next unless Moxml::Adapter.load_available?(adapter_name.to_sym)
9
+ rescue StandardError
10
+ false
11
+ end
12
+
13
+ RSpec.describe "Contract parity: #{adapter_name}", :adapter do
14
+ around do |example|
15
+ Moxml.with_config(adapter_name.to_sym, true, "UTF-8") do
16
+ example.run
17
+ end
18
+ end
19
+
20
+ let(:ctx) { Moxml.new(adapter_name.to_sym) }
21
+
22
+ describe "fragment parsing (issue #188)" do
23
+ it "returns the fragment's top-level nodes uniformly" do
24
+ nodes = ctx.parse_fragment(%(<a x="1">t1</a><b/>tail))
25
+ expect(nodes.map(&:class)).to eq(
26
+ [Moxml::Element, Moxml::Element, Moxml::Text],
27
+ )
28
+ expect(nodes[0]["x"]).to eq("1")
29
+ expect(nodes[0].text).to eq("t1")
30
+ expect(nodes[2].content).to eq("tail")
31
+ end
32
+
33
+ it "round-trips entities inside fragments" do
34
+ nodes = ctx.parse_fragment(%(<p>caf&eacute; &amp; more</p>))
35
+ # The moxml XML contract preserves entity REFERENCES in
36
+ # text reads (the entity-preservation design) — uniform
37
+ # across adapters, fragments included.
38
+ expect(nodes.first.content).to include("&eacute;")
39
+ expect(nodes.first.to_xml).to include("&amp;")
40
+ end
41
+
42
+ it "returns [] for an empty fragment" do
43
+ expect(ctx.parse_fragment("")).to eq([])
44
+ end
45
+ end
46
+
47
+ describe "nil namespace clearing (issue #164 contract)" do
48
+ it "accepts namespace = nil without raising" do
49
+ doc = ctx.parse(%(<r xmlns="urn:d"><c>t</c></r>))
50
+ child = doc.root.children.first
51
+ expect { child.namespace = nil }.not_to raise_error
52
+ end
53
+ end
54
+
55
+ describe "namespace adoption on append" do
56
+ it "keeps a subtree's own declarations when appended" do
57
+ doc = ctx.parse(%(<root/>))
58
+ nodes = ctx.parse_fragment(%(<p:s xmlns:p="urn:p">x</p:s>))
59
+ doc.root.add_child(nodes.first)
60
+ out = doc.to_xml
61
+ expect(out).to include("urn:p")
62
+ expect(out).to include("x")
63
+ end
64
+ end
65
+
66
+ describe "namespaces contract (issue #198 comment)" do
67
+ it "returns the in-scope map including ancestor declarations" do
68
+ doc = ctx.parse(%(<root xmlns:p="urn:p" xmlns="urn:d"><p:c plain="1"/></root>))
69
+ child = doc.root.children.first
70
+ map = child.namespaces.map { |n| [n.prefix, n.uri.to_s] }
71
+ expect(map).to contain_exactly(["p", "urn:p"], [nil, "urn:d"])
72
+ end
73
+
74
+ it "namespace_definitions stays the element's own declarations" do
75
+ doc = ctx.parse(%(<root xmlns:p="urn:p"><p:c/></root>))
76
+ child = doc.root.children.first
77
+ expect(child.namespace_definitions).to be_empty
78
+ expect(doc.root.namespace_definitions.map(&:prefix)).to eq(["p"])
79
+ end
80
+ end
81
+
82
+ describe "subtree digest channel (issue #173)" do
83
+ it "answers an Integer on digest-capable backends, nil elsewhere" do
84
+ doc = ctx.parse(%(<r><a x="1">t</a></r>))
85
+ digest = doc.root.digest
86
+ expect(digest).to be_nil.or be_a(Integer)
87
+ # Where non-nil, it is deterministic within the backend
88
+ doc2 = ctx.parse(%(<r><a x="1">t</a></r>))
89
+ expect(doc2.root.digest).to eq(digest) unless digest.nil?
90
+ end
91
+ end
92
+
93
+ describe "attribute channel semantics" do
94
+ it "reads and writes bare and prefixed attributes" do
95
+ doc = ctx.parse(%(<r xmlns:p="urn:p"><e a="1" p:b="2"/></r>))
96
+ e = doc.at_xpath("//e")
97
+ expect(e["a"]).to eq("1")
98
+ expect(e["p:b"]).to eq("2")
99
+ e["a"] = "changed"
100
+ expect(e["a"]).to eq("changed")
101
+ expect(e["p:b"]).to eq("2")
102
+ end
103
+
104
+ # Writes invalidate locally (no document-wide scope bump); a
105
+ # sibling's resolved reads must survive interleaved writes.
106
+ it "keeps sibling read caches coherent across interleaved writes" do
107
+ doc = ctx.parse(%(<r><a x="1"/><b x="2"/></r>))
108
+ first, second = doc.root.children.to_a
109
+ expect(first["x"]).to eq("1")
110
+ expect(second["x"]).to eq("2")
111
+ first["x"] = "one"
112
+ first["y"] = "fresh"
113
+ expect(second["x"]).to eq("2")
114
+ expect(first["x"]).to eq("one")
115
+ expect(first["y"]).to eq("fresh")
116
+ second["x"] = "two"
117
+ expect(first["x"]).to eq("one")
118
+ expect(first["y"]).to eq("fresh")
119
+ expect(second["x"]).to eq("two")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -134,7 +134,10 @@ RSpec.shared_examples "Moxml::Namespace" do
134
134
  child.add_namespace("dc", "http://purl.org/dc/elements/1.1/")
135
135
  root.add_child(child)
136
136
 
137
- ns_defs = child.namespaces
137
+ # Own declarations (the #namespaces name moved to the
138
+ # in-scope Nokogiri contract — issue #198; this assertion
139
+ # pins own-declarations, which is what it always meant).
140
+ ns_defs = child.namespace_definitions
138
141
  prefixes = ns_defs.map(&:prefix)
139
142
 
140
143
  expect(prefixes).to contain_exactly("dc")
@@ -634,4 +634,57 @@ RSpec.describe Moxml::Adapter::Leptris do
634
634
  expect(doc.at_xpath("//a").children.to_a.map(&:class)).to include(Moxml::EntityReference)
635
635
  end
636
636
  end
637
+
638
+ describe "subtree digest (issue #173, leptris#869)" do
639
+ let(:ctx) { Moxml.new(:leptris) }
640
+
641
+ it "answers equal integers for identical subtrees parsed separately" do
642
+ xml = %(<r xmlns:p="urn:p"><a x="1" p:y="2">t</a><b><c/></b></r>)
643
+ d1 = ctx.parse(xml)
644
+ d2 = ctx.parse(xml)
645
+ expect(d1.root.digest).to be_a(Integer)
646
+ expect(d1.root.digest).to eq(d2.root.digest)
647
+ end
648
+
649
+ it "answers unequal integers when content differs" do
650
+ d1 = ctx.parse(%(<r><a x="1"/></r>))
651
+ d2 = ctx.parse(%(<r><a x="2"/></r>))
652
+ expect(d1.root.digest).not_to eq(d2.root.digest)
653
+ end
654
+
655
+ it "skips whitespace-only text with drop_ws_text" do
656
+ spaced = ctx.parse(%(<r>\n <a/>\n</r>))
657
+ tight = ctx.parse(%(<r><a/></r>))
658
+ expect(spaced.root.digest).not_to eq(tight.root.digest)
659
+ expect(spaced.root.digest(drop_ws_text: true))
660
+ .to eq(tight.root.digest(drop_ws_text: true))
661
+ end
662
+
663
+ it "hashes the prefix as well as the resolved namespace" do
664
+ same = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
665
+ mirror = ctx.parse(%(<r xmlns:p="urn:p"><p:a/></r>))
666
+ renamed = ctx.parse(%(<r xmlns:q="urn:p"><q:a/></r>))
667
+ other_uri = ctx.parse(%(<r xmlns:p="urn:z"><p:a/></r>))
668
+ base = same.root.digest
669
+ expect(mirror.root.digest).to eq(base)
670
+ # prefix participates (leptris#869: hash(prefix, URI, local))
671
+ expect(renamed.root.digest).not_to eq(base)
672
+ expect(other_uri.root.digest).not_to eq(base)
673
+ end
674
+
675
+ it "answers nil for nodes without a C handle" do
676
+ doc = ctx.parse(%(<r a="1"><!-- c --><p/><?p instr?></r>))
677
+ expect(doc.digest).to be_nil
678
+ expect(doc.root.attributes.first.digest).to be_nil
679
+ doc.children.select(&:declaration?).each do |decl|
680
+ expect(decl.digest).to be_nil
681
+ end
682
+ # comments and PIs hash in C
683
+ kinds = doc.root.children.map { |n| [n.class, n.digest] }
684
+ comment = kinds.find { |n, _| n == Moxml::Comment }
685
+ pi = kinds.find { |n, _| n == Moxml::ProcessingInstruction }
686
+ expect(comment[1]).to be_a(Integer)
687
+ expect(pi[1]).to be_a(Integer)
688
+ end
689
+ end
637
690
  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.32
4
+ version: 0.5.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -38,6 +38,7 @@ files:
38
38
  - LICENSE.md
39
39
  - README.adoc
40
40
  - Rakefile
41
+ - benchmark/pipeline_bench.rb
41
42
  - benchmarks/.gitignore
42
43
  - benchmarks/generate_report.rb
43
44
  - bin/console
@@ -395,6 +396,7 @@ files:
395
396
  - spec/fixtures/xmldsig/sign3-result.xml
396
397
  - spec/integration/README.md
397
398
  - spec/integration/all_adapters_spec.rb
399
+ - spec/integration/contract_parity_spec.rb
398
400
  - spec/integration/headed_ox_integration_spec.rb
399
401
  - spec/integration/sax_parity_spec.rb
400
402
  - spec/integration/shared_examples/edge_cases.rb