moxml 0.5.7 → 0.5.8

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: c0d0117dfa9a9f81f628f2200d09b21d93d148a1dd4d0614c98749c90cdb569e
4
- data.tar.gz: 85de05e0fe6d7d596b8e39508adbcd98c312b63d16d10eb146caab8f147cdb08
3
+ metadata.gz: 77a959afbe83b0cc46a158571f792d07888f7bf29f3a65c1ea88be867892ffcc
4
+ data.tar.gz: 1a5a9515ba125977cd26e33b878e7ecaaed1a788e450e79f356c54a117c9e6c7
5
5
  SHA512:
6
- metadata.gz: a57ef921cc44f3706e541abce2bef21b5b9f91066d9bc3528be802a373d57a488fb36825bf13dcf083aa88572fe1d2618ddbbb0d4ebec9a3de93515006698974
7
- data.tar.gz: db325b0220a05b14bc2e037250c75319a60be30e652d05a1519ea0d26b5ff3afa55031d467312e81388442cb4f172513ef64cfb42be37ddca4c9da4d46704e97
6
+ metadata.gz: ff6e651e53a55b5ed4aed1e68eff9fa40e9a58aed81a3709ce101fb315bddbef928dcd1a8b31317fcf7dd42e65b92158191647365bc7c43fba46677551bd82d6
7
+ data.tar.gz: 3c3ad8fac372d42a2dd9d3e4660bc30987015975031fed6b80c1bfeea864f47ed36676d125132384b72d7ba51a3a1ef743877efd5611fd0ecc2cda96efae29e7
@@ -189,6 +189,15 @@ namespace_validation_mode: :strict)
189
189
  true
190
190
  end
191
191
 
192
+ # Whether the engine offers a bulk materialization path for
193
+ # Materializer (issue #132). When true, materialize_records
194
+ # yields flattened records for the subtree; returning nil
195
+ # (e.g. for document shapes the bulk path cannot express)
196
+ # falls back to the generic wrapper walk.
197
+ def bulk_materialize?
198
+ false
199
+ end
200
+
192
201
  # Check if the native document has an XML declaration
193
202
  # @param native_doc the native document object
194
203
  # @param wrapper [Moxml::Document] the wrapper with has_xml_declaration flag
@@ -32,8 +32,11 @@ module Moxml
32
32
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
33
33
  processed = preprocess_entities(xml_string)
34
34
 
35
+ # readonly: true (issue #133): the binding memoizes reads and
36
+ # refuses mutations — the parse-and-read lifecycle for
37
+ # multi-pass consumers (comparison, diff, signature).
35
38
  native_doc = begin
36
- ::Leptris::XML::Document.parse(processed)
39
+ ::Leptris::XML::Document.parse(processed, readonly: options[:readonly] == true)
37
40
  rescue ::Leptris::XML::ParseError => e
38
41
  # libleptris has no recovery mode that survives unclosed
39
42
  # tags; non-strict callers get an empty document, matching
@@ -224,6 +227,88 @@ module Moxml
224
227
  end
225
228
  end
226
229
 
230
+ # Bulk materialization (issue #132): leptris_node_traverse
231
+ # walks the subtree with one FFI call; the only per-node cost
232
+ # is the C->Ruby callback and the property reads below. No
233
+ # Moxml::Node or Attribute wrapper is allocated.
234
+ def bulk_materialize?
235
+ true
236
+ end
237
+
238
+ def materialize_records(native, &block)
239
+ doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
240
+ # Marker-bearing text needs the split pipeline (children-level
241
+ # ER expansion); the bulk path has no marker handling.
242
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
243
+
244
+ root = native.is_a?(::Leptris::XML::Document) ? native.root : native
245
+ return nil if root.nil?
246
+
247
+ depth_memo = {}.compare_by_identity
248
+ root.traverse do |node|
249
+ record = case node
250
+ when ::Leptris::XML::Element
251
+ element_material_record(node, depth_memo)
252
+ when ::Leptris::XML::CDATA
253
+ # CDATA < Text in the binding: this arm must come
254
+ # first or CDATA content reports as text.
255
+ text_material_record(:cdata, node.content, node, depth_memo)
256
+ when ::Leptris::XML::Text
257
+ text_material_record(:text, node.content, node, depth_memo)
258
+ when ::Leptris::XML::Comment
259
+ text_material_record(:comment, node.content, node, depth_memo)
260
+ when ::Leptris::XML::ProcessingInstruction
261
+ text_material_record(:processing_instruction, node.content, node, depth_memo)
262
+ .merge(qname: node.target)
263
+ end
264
+ yield(record) if record
265
+ end
266
+ true
267
+ end
268
+
269
+ def element_material_record(node, depth_memo)
270
+ attributes = node.each_attribute.map do |attr|
271
+ # Local name + separate prefix, matching the generic
272
+ # path's resolver semantics (moxml's canonical shape).
273
+ name = attr.name
274
+ prefix = attr.prefix
275
+ name = name.split(":", 2)[1] || name if prefix
276
+ [name, attr.value, attr.namespace_uri, prefix]
277
+ end
278
+ ns = node.namespace
279
+ {
280
+ kind: :element,
281
+ qname: node.name,
282
+ prefix: node.prefix,
283
+ namespace_uri: ns&.href,
284
+ attributes: attributes,
285
+ text: nil,
286
+ depth: material_depth(node, depth_memo),
287
+ }
288
+ end
289
+
290
+ def text_material_record(kind, text, node, depth_memo)
291
+ {
292
+ kind: kind,
293
+ qname: nil,
294
+ prefix: nil,
295
+ namespace_uri: nil,
296
+ attributes: Materializer::EMPTY_ATTRIBUTES,
297
+ text: text,
298
+ depth: material_depth(node, depth_memo),
299
+ }
300
+ end
301
+
302
+ # Binding wrappers are address-stable and #parent is memoized,
303
+ # so each edge is fetched once; depths resolve through the
304
+ # identity-keyed memo.
305
+ def material_depth(node, memo)
306
+ memo[node] ||= begin
307
+ parent = node.parent
308
+ parent.nil? || parent.is_a?(::Leptris::XML::Document) ? 0 : material_depth(parent, memo) + 1
309
+ end
310
+ end
311
+
227
312
  def node_type(node)
228
313
  case node
229
314
  when ::Leptris::XML::Document then :document
data/lib/moxml/context.rb CHANGED
@@ -74,6 +74,13 @@ module Moxml
74
74
  doc
75
75
  end
76
76
 
77
+ # Parse then flatten in one call — see Moxml::Materializer
78
+ # (issue #132). Yields records; returns an Enumerator when no
79
+ # block is given.
80
+ def materialize(xml, options = {}, &block)
81
+ parse(xml, options).materialize(&block)
82
+ end
83
+
77
84
  # Parse XML using SAX (event-driven) parsing
78
85
  #
79
86
  # SAX parsing is memory-efficient and suitable for large XML files.
@@ -24,6 +24,13 @@ module Moxml
24
24
  root_element ? Moxml::Node.wrap(root_element, context) : nil
25
25
  end
26
26
 
27
+ # Materialize the root subtree — see Moxml::Materializer.
28
+ def materialize(&block)
29
+ return to_enum(:materialize) unless block
30
+
31
+ root&.materialize(&block)
32
+ end
33
+
27
34
  def create_element(name)
28
35
  Element.new(adapter.create_element(name, owner_doc: @native), context)
29
36
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Flattened subtree records for conversion-style consumers
5
+ # (issue #132): one record per node, emitted in post-order (children
6
+ # before parents — build with a stack), carrying everything a
7
+ # consumer tree needs without allocating Moxml::Node or
8
+ # Moxml::Attribute wrappers.
9
+ #
10
+ # context.materialize(xml) { |r| ... }
11
+ # document.materialize.each { |r| ... } # Enumerator
12
+ #
13
+ # Record shape:
14
+ # { kind: :element|:text|:cdata|:comment|:processing_instruction,
15
+ # qname: "tag"|"pi-target"|nil, prefix: "p"|nil,
16
+ # namespace_uri: "urn:x"|nil,
17
+ # attributes: [[name, value, namespace_uri, prefix], ...],
18
+ # text: String|nil, depth: Integer }
19
+ #
20
+ # Adapters with a bulk path (leptris: one leptris_node_traverse FFI
21
+ # call for the whole subtree) answer bulk_materialize?; the others
22
+ # walk the wrapper tree generically.
23
+ module Materializer
24
+ # Shared frozen empty attribute list for non-element records;
25
+ # adapters' bulk paths reference it too.
26
+ EMPTY_ATTRIBUTES = [].freeze
27
+
28
+ module_function
29
+
30
+ def materialize(node, &block)
31
+ adapter = node.context.config.adapter
32
+ return enum_for(:materialize, node) unless block
33
+
34
+ if adapter.bulk_materialize? && adapter.materialize_records(node.native, &block)
35
+ return
36
+ end
37
+
38
+ walk(node, 0, &block)
39
+ end
40
+
41
+ # Generic post-order walk over the wrapper tree. Works on every
42
+ # adapter; the fast bulk path exists where the engine offers one.
43
+ def walk(node, depth, &block)
44
+ case node
45
+ when Element
46
+ node.children.each { |child| walk(child, depth + 1, &block) }
47
+ yield(element_record(node, depth))
48
+ when Text
49
+ yield(text_record(:text, node.content, depth))
50
+ when Cdata
51
+ yield(text_record(:cdata, node.content, depth))
52
+ when Comment
53
+ yield(text_record(:comment, node.content, depth))
54
+ when ProcessingInstruction
55
+ yield(text_record(:processing_instruction, node.content, depth).merge(qname: node.target))
56
+ when EntityReference
57
+ yield(text_record(:entity_reference, "&#{node.name};", depth).merge(qname: node.name))
58
+ end
59
+ end
60
+
61
+ def element_record(element, depth)
62
+ attributes = element.attributes.map do |attr|
63
+ ns = attr.namespace
64
+ [attr.name, attr.value, ns&.uri, ns&.prefix]
65
+ end
66
+ ns = element.namespace
67
+ {
68
+ kind: :element,
69
+ qname: element.name,
70
+ prefix: element.namespace_prefix,
71
+ namespace_uri: ns&.uri,
72
+ attributes: attributes,
73
+ text: nil,
74
+ depth: depth,
75
+ }
76
+ end
77
+
78
+ def text_record(kind, text, depth)
79
+ {
80
+ kind: kind,
81
+ qname: nil,
82
+ prefix: nil,
83
+ namespace_uri: nil,
84
+ attributes: EMPTY_ATTRIBUTES,
85
+ text: text,
86
+ depth: depth,
87
+ }
88
+ end
89
+ end
90
+ end
data/lib/moxml/node.rb CHANGED
@@ -124,6 +124,13 @@ module Moxml
124
124
  result.is_a?(Array) ? NodeSet.new(result, context) : result
125
125
  end
126
126
 
127
+ # Flattened post-order records for this subtree without
128
+ # allocating wrappers — see Moxml::Materializer (issue #132).
129
+ # Returns an Enumerator when no block is given.
130
+ def materialize(&block)
131
+ Materializer.materialize(self, &block)
132
+ end
133
+
127
134
  def at_xpath(expression, namespaces = {})
128
135
  Moxml::Node.wrap(adapter.at_xpath(@native, expression, namespaces),
129
136
  context)
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.7"
4
+ VERSION = "0.5.8"
5
5
  end
data/lib/moxml.rb CHANGED
@@ -87,6 +87,7 @@ module Moxml
87
87
  autoload :NativeAttachment, "moxml/native_attachment"
88
88
  autoload :XmlUtils, "moxml/xml_utils"
89
89
  autoload :XmlEmitter, "moxml/xml_emitter"
90
+ autoload :Materializer, "moxml/materializer"
90
91
  autoload :Adapter, "moxml/adapter"
91
92
  autoload :XPath, "moxml/xpath"
92
93
  autoload :SAX, "moxml/sax"
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Moxml::Materializer do
6
+ let(:xml) do
7
+ <<~XML
8
+ <catalog xmlns="urn:c" xmlns:p="urn:p">
9
+ <book id="b1" p:lang="en"><title>T</title><!--n--><?pi x?><![CDATA[raw]]></book>
10
+ </catalog>
11
+ XML
12
+ end
13
+
14
+ def records_for(adapter)
15
+ Moxml.new(adapter).materialize(xml).to_a
16
+ end
17
+
18
+ it "emits one record per node in post-order with depth, names, and attributes" do
19
+ records = records_for(:leptris)
20
+
21
+ expect(records.map { |r| [r[:kind], r[:depth]] }).to eq(
22
+ [
23
+ # post-order: whitespace before <book>, then the book subtree,
24
+ # then the trailing whitespace, then the catalog itself
25
+ [:text, 1],
26
+ [:text, 3], [:element, 2], [:comment, 2],
27
+ [:processing_instruction, 2], [:cdata, 2],
28
+ [:element, 1],
29
+ [:text, 1],
30
+ [:element, 0]
31
+ ],
32
+ )
33
+
34
+ book = records.find { |r| r[:qname] == "book" }
35
+ expect(book[:attributes]).to include(["id", "b1", nil, nil], ["lang", "en", "urn:p", "p"])
36
+ expect(book[:namespace_uri]).to eq("urn:c")
37
+ expect(book[:text]).to be_nil
38
+
39
+ title_text = records.find { |r| r[:kind] == :text && r[:text] == "T" }
40
+ expect(title_text[:attributes]).to eq([])
41
+ end
42
+
43
+ it "uses the same record stream on the bulk path and the generic walk" do
44
+ skip "leptris not installed" unless Moxml::Adapter.available?(:leptris)
45
+
46
+ expect(records_for(:leptris)).to eq(records_for(:nokogiri))
47
+ end
48
+
49
+ it "falls back to the generic walk for marker-bearing documents" do
50
+ entity_xml = "<r><a>pre&nbsp;post</a></r>"
51
+ records = Moxml.new(:leptris).materialize(entity_xml).to_a
52
+
53
+ expect(records.map { |r| [r[:kind], r[:text]] }).to eq(
54
+ [[:text, "pre"], [:entity_reference, "&nbsp;"], [:text, "post"],
55
+ [:element, nil], [:element, nil]],
56
+ )
57
+ end
58
+
59
+ it "materializes from any element subtree" do
60
+ doc = Moxml.new(:nokogiri).parse(xml)
61
+ title = doc.at_xpath("//*[local-name()='title']")
62
+ records = title.materialize.to_a
63
+
64
+ expect(records.map { |r| [r[:kind], r[:depth]] }).to eq([[:text, 1], [:element, 0]])
65
+ end
66
+
67
+ it "allocates no wrappers on the bulk path" do
68
+ skip "leptris not installed" unless Moxml::Adapter.available?(:leptris)
69
+
70
+ ctx = Moxml.new(:leptris)
71
+ ctx.materialize(xml).to_a # warm
72
+ GC.start
73
+ before = GC.stat(:total_allocated_objects)
74
+ ctx.materialize(xml).to_a
75
+ allocated = GC.stat(:total_allocated_objects) - before
76
+ node_count = ctx.parse(xml).materialize.count
77
+
78
+ # Records themselves allocate (hash + FFI strings); the wrapper
79
+ # tree does not: far fewer allocations than 2 wrappers per node.
80
+ expect(allocated).to be < node_count * 20
81
+ end
82
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "readonly parsing" do
6
+ let(:xml) { '<r><a x="1">t</a></r>' }
7
+
8
+ it "parses and reads normally" do
9
+ doc = Moxml.new(:leptris).parse(xml, readonly: true)
10
+
11
+ expect(doc.root.name).to eq("r")
12
+ expect(doc.at_xpath("//a")["x"]).to eq("1")
13
+ expect(doc.at_xpath("//a").text).to eq("t")
14
+ end
15
+
16
+ it "refuses mutations" do
17
+ doc = Moxml.new(:leptris).parse(xml, readonly: true)
18
+
19
+ expect { doc.root["y"] = "2" }.to raise_error(/ReadOnly/i)
20
+ end
21
+
22
+ it "is a no-op concept on other adapters" do
23
+ doc = Moxml.new(:nokogiri).parse(xml, readonly: true)
24
+
25
+ expect(doc.root.name).to eq("r")
26
+ end
27
+ 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.7
4
+ version: 0.5.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -196,6 +196,7 @@ files:
196
196
  - lib/moxml/entity_registry.rb
197
197
  - lib/moxml/entity_registry_opal_data.rb
198
198
  - lib/moxml/error.rb
199
+ - lib/moxml/materializer.rb
199
200
  - lib/moxml/namespace.rb
200
201
  - lib/moxml/native_attachment.rb
201
202
  - lib/moxml/native_attachment/native.rb
@@ -454,6 +455,7 @@ files:
454
455
  - spec/moxml/entity_spec.rb
455
456
  - spec/moxml/error_spec.rb
456
457
  - spec/moxml/lazy_parse_spec.rb
458
+ - spec/moxml/materializer_spec.rb
457
459
  - spec/moxml/moxml_spec.rb
458
460
  - spec/moxml/namespace_spec.rb
459
461
  - spec/moxml/namespace_uri_validation_spec.rb
@@ -472,6 +474,7 @@ files:
472
474
  - spec/moxml/opal_rexml_adapter_spec.rb
473
475
  - spec/moxml/opal_smoke_spec.rb
474
476
  - spec/moxml/processing_instruction_spec.rb
477
+ - spec/moxml/readonly_parse_spec.rb
475
478
  - spec/moxml/sax/namespace_splitter_spec.rb
476
479
  - spec/moxml/sax_entity_parity_spec.rb
477
480
  - spec/moxml/sax_spec.rb