moxml 0.5.7 → 0.5.9

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: 011d9a8566b2697d8c01a17a1a4f702ace315361dae3b246f8ed5ad5fb79b3e8
4
+ data.tar.gz: 286839c9aca15e4027d4b01bcdfb432c2036f10234fb408cf10f88e5dcbeac0d
5
5
  SHA512:
6
- metadata.gz: a57ef921cc44f3706e541abce2bef21b5b9f91066d9bc3528be802a373d57a488fb36825bf13dcf083aa88572fe1d2618ddbbb0d4ebec9a3de93515006698974
7
- data.tar.gz: db325b0220a05b14bc2e037250c75319a60be30e652d05a1519ea0d26b5ff3afa55031d467312e81388442cb4f172513ef64cfb42be37ddca4c9da4d46704e97
6
+ metadata.gz: f53698ba5eee597fb6bda3355c1f7dfe0a33442cd3e67a6aedc73a155a23e80f049f2fc63aed6871b6cb2e85723719d7e7f318cc140ef178e5dde4bcd493a095
7
+ data.tar.gz: 44d1afe3880ebc55dd214a0a7f2b48c69151504b6c5a16646afd88cf4a885184132db9a590552c2531bfda8ea84a92e8fac50a7cfae75379e215582acf0f5bac
@@ -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
@@ -30,10 +30,15 @@ module Moxml
30
30
 
31
31
  def parse(xml, options = {}, _context = nil)
32
32
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
33
- processed = preprocess_entities(xml_string)
33
+ # The marker flag rides preprocess's own `&` scan — no
34
+ # second full-buffer probe (issue #132 parse-side note).
35
+ processed, entity_markers = Entity.preprocess_with_marker_flag(xml_string)
34
36
 
37
+ # readonly: true (issue #133): the binding memoizes reads and
38
+ # refuses mutations — the parse-and-read lifecycle for
39
+ # multi-pass consumers (comparison, diff, signature).
35
40
  native_doc = begin
36
- ::Leptris::XML::Document.parse(processed)
41
+ ::Leptris::XML::Document.parse(processed, readonly: options[:readonly] == true)
37
42
  rescue ::Leptris::XML::ParseError => e
38
43
  # libleptris has no recovery mode that survives unclosed
39
44
  # tags; non-strict callers get an empty document, matching
@@ -46,7 +51,7 @@ module Moxml
46
51
  doc = Document.new(native_doc, ctx)
47
52
 
48
53
  record_source_declaration(native_doc, processed)
49
- attachments.set(native_doc, :entity_markers, processed.include?(Entity::MARKER))
54
+ attachments.set(native_doc, :entity_markers, entity_markers)
50
55
 
51
56
  doc
52
57
  end
@@ -224,18 +229,104 @@ module Moxml
224
229
  end
225
230
  end
226
231
 
232
+ # Bulk materialization (issue #132): leptris_node_traverse
233
+ # walks the subtree with one FFI call; the only per-node cost
234
+ # is the C->Ruby callback and the property reads below. No
235
+ # Moxml::Node or Attribute wrapper is allocated.
236
+ def bulk_materialize?
237
+ true
238
+ end
239
+
240
+ def materialize_records(native, &block)
241
+ doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
242
+ # Marker-bearing text needs the split pipeline (children-level
243
+ # ER expansion); the bulk path has no marker handling.
244
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
245
+
246
+ root = native.is_a?(::Leptris::XML::Document) ? native.root : native
247
+ return nil if root.nil?
248
+
249
+ depth_memo = {}.compare_by_identity
250
+ root.traverse do |node|
251
+ record = case node
252
+ when ::Leptris::XML::Element
253
+ element_material_record(node, depth_memo)
254
+ when ::Leptris::XML::CDATA
255
+ # CDATA < Text in the binding: this arm must come
256
+ # first or CDATA content reports as text.
257
+ text_material_record(:cdata, node.content, node, depth_memo)
258
+ when ::Leptris::XML::Text
259
+ text_material_record(:text, node.content, node, depth_memo)
260
+ when ::Leptris::XML::Comment
261
+ text_material_record(:comment, node.content, node, depth_memo)
262
+ when ::Leptris::XML::ProcessingInstruction
263
+ text_material_record(:processing_instruction, node.content, node, depth_memo)
264
+ .merge(qname: node.target)
265
+ end
266
+ yield(record) if record
267
+ end
268
+ true
269
+ end
270
+
271
+ def element_material_record(node, depth_memo)
272
+ attributes = node.each_attribute.map do |attr|
273
+ # Local name + separate prefix, matching the generic
274
+ # path's resolver semantics (moxml's canonical shape).
275
+ name = attr.name
276
+ prefix = attr.prefix
277
+ name = name.split(":", 2)[1] || name if prefix
278
+ [name, attr.value, attr.namespace_uri, prefix]
279
+ end
280
+ ns = node.namespace
281
+ {
282
+ kind: :element,
283
+ qname: node.name,
284
+ prefix: node.prefix,
285
+ namespace_uri: ns&.href,
286
+ attributes: attributes,
287
+ text: nil,
288
+ depth: material_depth(node, depth_memo),
289
+ }
290
+ end
291
+
292
+ def text_material_record(kind, text, node, depth_memo)
293
+ {
294
+ kind: kind,
295
+ qname: nil,
296
+ prefix: nil,
297
+ namespace_uri: nil,
298
+ attributes: Materializer::EMPTY_ATTRIBUTES,
299
+ text: text,
300
+ depth: material_depth(node, depth_memo),
301
+ }
302
+ end
303
+
304
+ # Binding wrappers are address-stable and #parent is memoized,
305
+ # so each edge is fetched once; depths resolve through the
306
+ # identity-keyed memo.
307
+ def material_depth(node, memo)
308
+ memo[node] ||= begin
309
+ parent = node.parent
310
+ parent.nil? || parent.is_a?(::Leptris::XML::Document) ? 0 : material_depth(parent, memo) + 1
311
+ end
312
+ end
313
+
227
314
  def node_type(node)
315
+ # Frequency-ordered: elements and text dominate every real
316
+ # document, and Node.wrap dispatches here once per cold
317
+ # wrap. CDATA must precede Text (CDATA < Text in the
318
+ # binding).
228
319
  case node
320
+ when ::Leptris::XML::Element then :element
321
+ when ::Leptris::XML::CDATA then :cdata
322
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment then :text
323
+ when ::Leptris::XML::Attr then :attribute
324
+ when ::Leptris::XML::Comment then :comment
325
+ when ::Leptris::XML::ProcessingInstruction, CustomizedLeptris::DocumentPI then :processing_instruction
229
326
  when ::Leptris::XML::Document then :document
230
327
  when ::Leptris::XML::DocType, CustomizedLeptris::Doctype then :doctype
231
328
  when CustomizedLeptris::Declaration then :declaration
232
329
  when CustomizedLeptris::EntityReference then :entity_reference
233
- when ::Leptris::XML::CDATA then :cdata
234
- when ::Leptris::XML::Comment then :comment
235
- when ::Leptris::XML::ProcessingInstruction, CustomizedLeptris::DocumentPI then :processing_instruction
236
- when ::Leptris::XML::Text, CustomizedLeptris::TextSegment then :text
237
- when ::Leptris::XML::Element then :element
238
- when ::Leptris::XML::Attr then :attribute
239
330
  else :unknown
240
331
  end
241
332
  end
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
data/lib/moxml/entity.rb CHANGED
@@ -55,9 +55,12 @@ module Moxml
55
55
  module_function
56
56
 
57
57
  # Replace non-standard entity references with markers before
58
- # parsing. Always returns a UTF-8 encoded string.
59
- def preprocess_entities(xml)
60
- return "" if xml.nil?
58
+ # parsing. Always returns a UTF-8 encoded string, and reports
59
+ # whether the result can contain markers: the flag rides the
60
+ # same `&` scan, so callers needing it (leptris parse) avoid a
61
+ # second full-buffer multibyte probe.
62
+ def preprocess_with_marker_flag(xml)
63
+ return ["", false] if xml.nil?
61
64
 
62
65
  str = if xml.encoding == Encoding::BINARY
63
66
  # Binary strings are assumed to be UTF-8. If the bytes are
@@ -78,11 +81,23 @@ module Moxml
78
81
  # Fast path: no `&` means no entity references to mark — skip
79
82
  # the regex scan and string allocation entirely. The vast
80
83
  # majority of XML payloads contain no entity references.
81
- return str unless str.include?("&")
84
+ return [str, false] unless str.include?("&")
82
85
 
83
- str.gsub(NAME_RE) do |match|
84
- STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{MARKER}#{::Regexp.last_match(1)};"
86
+ marked = false
87
+ processed = str.gsub(NAME_RE) do |match|
88
+ name = ::Regexp.last_match(1)
89
+ if STANDARD_ENTITIES.include?(name)
90
+ match
91
+ else
92
+ marked = true
93
+ "#{MARKER}#{name};"
94
+ end
85
95
  end
96
+ [processed, marked]
97
+ end
98
+
99
+ def preprocess_entities(xml)
100
+ preprocess_with_marker_flag(xml)[0]
86
101
  end
87
102
 
88
103
  # Resolve numeric (&#NN; / &#xNN;) and the five standard named
@@ -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.9"
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.9
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