Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ gem "rubocop-performance"
gem "rubocop-rake"
gem "rubocop-rspec"
gem "simplecov", require: false
gem "stackprof"
gem "tempfile"

# Needed by get_process_mem on Windows
Expand Down
7 changes: 5 additions & 2 deletions lib/moxml/adapter/headed_ox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ module Adapter
#
class HeadedOx < Ox
class << self
# Override parse to use HeadedOx context instead of Ox context
# Override parse to use lazy wrapping like the Ox adapter.
# Previously used DocumentBuilder (eager tree construction causing
# ~176K allocations per 100-element parse). Lazy parse defers wrapper
# creation until nodes are accessed, matching Ox adapter behavior.
def parse(xml, _options = {}, _context = nil)
native_doc = begin
result = ::Ox.parse(xml)
Expand All @@ -47,7 +50,7 @@ def parse(xml, _options = {}, _context = nil)

# Use provided context if available, otherwise create new one
ctx = _context || Context.new(:headed_ox)
DocumentBuilder.new(ctx).build(native_doc)
Document.new(native_doc, ctx)
end

# Execute XPath query using Moxml's XPath engine
Expand Down
2 changes: 1 addition & 1 deletion lib/moxml/adapter/nokogiri.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def parse(xml, options = {}, _context = nil)

# Use provided context if available, otherwise create new one
ctx = _context || Context.new(:nokogiri)
DocumentBuilder.new(ctx).build(native_doc)
Document.new(native_doc, ctx)
end

# SAX parsing implementation for Nokogiri
Expand Down
8 changes: 6 additions & 2 deletions lib/moxml/adapter/ox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def parse(xml, _options = {}, _context = nil)
end

ctx = _context || Context.new(:ox)
DocumentBuilder.new(ctx).build(native_doc)
Document.new(native_doc, ctx)
end

# SAX parsing implementation for Ox
Expand Down Expand Up @@ -238,7 +238,11 @@ def unpatch_node(node)
def children(node)
return [] unless node.respond_to?(:nodes)

node.nodes || []
result = node.nodes || []
# Ox doesn't set parent references during parsing.
# Set them here so parent/sibling navigation works.
result.each { |child| child.parent = node if child.respond_to?(:parent=) }
result
end

def parent(node)
Expand Down
6 changes: 5 additions & 1 deletion lib/moxml/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ def document

def root=(element)
adapter.set_root(@native, element.native)
element.instance_variable_set(:@parent_node, self)
invalidate_children_cache!
end

def root
root_element = adapter.root(@native)
root_element ? Element.wrap(root_element, context) : nil
root_element ? Element.new(root_element, context) : nil
end

def create_element(name)
Expand Down Expand Up @@ -91,6 +93,8 @@ def add_child(node)
else
adapter.add_child(@native, node.native)
end
node.instance_variable_set(:@parent_node, self)
invalidate_children_cache!
self
end

Expand Down
5 changes: 1 addition & 4 deletions lib/moxml/document_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,7 @@ def visit_entity_reference(node)
end

def visit_children(node)
node_children = children(node).dup
node_children.each do |child|
visit_node(child)
end
children(node).each { |child| visit_node(child) }
end

def node_type(node)
Expand Down
10 changes: 8 additions & 2 deletions lib/moxml/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def namespace_uri

def []=(name, value)
adapter.set_attribute(@native, name, normalize_xml_value(value))
@attributes_cache = nil
end

def [](name)
Expand All @@ -64,19 +65,21 @@ def get(attr_name)
end

def attributes
adapter.attributes(@native).map do |attr|
@attributes_cache ||= adapter.attributes(@native).map do |attr|
Attribute.new(attr, context)
end
end

def remove_attribute(name)
adapter.remove_attribute(@native, name)
@attributes_cache = nil
self
end

def add_namespace(prefix, uri)
adapter.create_namespace(@native, prefix, uri,
namespace_uri_mode: context.config.namespace_uri_mode)
@namespaces_cache = nil
self
rescue ValidationError => e
# Re-raise as NamespaceError, provide attributes for error context
Expand Down Expand Up @@ -108,10 +111,11 @@ def namespace=(ns_or_hash)
else
adapter.set_namespace(@native, ns_or_hash&.native)
end
@namespaces_cache = nil
end

def namespaces
adapter.namespace_definitions(@native).map do |ns|
@namespaces_cache ||= adapter.namespace_definitions(@native).map do |ns|
Namespace.new(ns, context)
end
end
Expand All @@ -128,6 +132,7 @@ def text

def text=(content)
adapter.set_text_content(@native, normalize_xml_value(content))
invalidate_children_cache!
end

def inner_text
Expand All @@ -141,6 +146,7 @@ def inner_xml
def inner_xml=(xml)
doc = context.parse("<root>#{xml}</root>")
adapter.replace_children(@native, doc.root.children.map(&:native))
invalidate_children_cache!
end

# Fluent interface methods
Expand Down
25 changes: 23 additions & 2 deletions lib/moxml/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ class Node

def initialize(native, context)
@context = context
# @native = adapter.patch_node(native)
@native = native
@parent_node = nil
end

def document
Expand All @@ -29,9 +29,10 @@ def parent
end

def children
NodeSet.new(
@children ||= NodeSet.new(
adapter.children(@native).map { adapter.patch_node(_1, @native) },
context,
self,
)
end

Expand All @@ -46,29 +47,37 @@ def previous_sibling
def add_child(node)
node = prepare_node(node)
adapter.add_child(@native, node.native)
node.instance_variable_set(:@parent_node, self)
invalidate_children_cache!
self
end

def add_previous_sibling(node)
node = prepare_node(node)
adapter.add_previous_sibling(@native, node.native)
invalidate_parent_children_cache!
self
end

def add_next_sibling(node)
node = prepare_node(node)
adapter.add_next_sibling(@native, node.native)
invalidate_parent_children_cache!
self
end

def remove
invalidate_parent_children_cache!
adapter.remove(@native)
invalidate_children_cache!
self
end

def replace(node)
node = prepare_node(node)
invalidate_parent_children_cache!
adapter.replace(@native, node.native)
invalidate_children_cache!
self
end

Expand Down Expand Up @@ -229,6 +238,18 @@ def self.adapter(context)
context.config.adapter
end

# Invalidate cached children. Called by mutation methods
# and by Element attribute/namespace caches.
def invalidate_children_cache!
@children = nil
end

# Invalidate parent's cached children when this node
# is removed/replaced from its parent's child list.
def invalidate_parent_children_cache!
@parent_node&.invalidate_children_cache!
end

private

def prepare_node(node)
Expand Down
57 changes: 42 additions & 15 deletions lib/moxml/node_set.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,60 +6,71 @@ class NodeSet

attr_reader :nodes, :context

def initialize(nodes, context)
def initialize(nodes, context, parent_node = nil)
@nodes = Array(nodes)
@context = context
@wrapped = Array.new(@nodes.size)
@parent_node = parent_node
end

def each
return to_enum(:each) unless block_given?

nodes.each { |node| yield Moxml::Node.wrap(node, context) }
@nodes.each_with_index do |node, i|
@wrapped[i] ||= wrap_with_parent(node)
yield @wrapped[i]
end
self
end

def [](index)
case index
when Integer
Moxml::Node.wrap(nodes[index], context)
return nil unless index >= 0 && index < @nodes.size

@wrapped[index] ||= wrap_with_parent(@nodes[index])
when Range
NodeSet.new(nodes[index], context)
self.class.new(@nodes[index], @context)
end
end

def first(n = nil)
if n.nil?
Moxml::Node.wrap(nodes.first, context)
@nodes.empty? ? nil : self[0]
else
nodes.first(n).map { |node| Moxml::Node.wrap(node, context) }
n.times.filter_map { |i| self[i] }
end
end

def last
Moxml::Node.wrap(nodes.last, context)
@nodes.empty? ? nil : self[@nodes.size - 1]
end

def empty?
nodes.empty?
@nodes.empty?
end

def size
nodes.size
@nodes.size
end
alias length size

def to_a
map { |node| node }
@nodes.each_with_index do |_node, i|
@wrapped[i] ||= wrap_with_parent(@nodes[i])
end
@wrapped.compact
end

def +(other)
self.class.new(nodes + other.nodes, context)
self.class.new(@nodes + other.nodes, @context)
end

def <<(node)
# If it's a wrapped Moxml node, unwrap to native before storing
native_node = node.respond_to?(:native) ? node.native : node
@nodes << native_node
@wrapped << nil
self
end
alias push <<
Expand All @@ -78,14 +89,14 @@ def uniq_by_native
true
end
end
self.class.new(unique_natives, context)
self.class.new(unique_natives, @context)
end

def ==(other)
self.class == other.class &&
length == other.length &&
nodes.each_with_index.all? do |node, index|
Moxml::Node.wrap(node, context) == other[index]
@nodes.each_with_index.all? do |_node, index|
self[index] == other[index]
end
end

Expand All @@ -103,8 +114,24 @@ def remove
def delete(node)
# If it's a wrapped Moxml node, unwrap to native
native_node = node.respond_to?(:native) ? node.native : node
@nodes.delete(native_node)
idx = @nodes.index(native_node)
if idx
@nodes.delete_at(idx)
@wrapped.delete_at(idx)
else
@nodes.delete(native_node)
end
self
end

private

def wrap_with_parent(native_node)
wrapped = Moxml::Node.wrap(native_node, @context)
if @parent_node && wrapped
wrapped.instance_variable_set(:@parent_node, @parent_node)
end
wrapped
end
end
end
13 changes: 1 addition & 12 deletions spec/moxml/adapter/shared_examples/adapter_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,7 @@
# A better way is to run it through Moxml wrappers
RSpec.shared_examples "xml adapter" do
let(:xml) do
<<~XML
<?xml version="1.0"?>
<root xmlns="http://example.org" xmlns:x="http://example.org/x">
<child id="1">Text</child>
<child id="2"/>
<x:special>
<![CDATA[Some <special> text]]>
<!-- A comment -->
<?pi target?>
</x:special>
</root>
XML
'<?xml version="1.0"?><root xmlns="http://example.org" xmlns:x="http://example.org/x"><child id="1">Text</child><child id="2"/><x:special><![CDATA[Some <special> text]]><!-- A comment --><?pi target?></x:special></root>'
end

describe ".parse" do
Expand Down
Loading
Loading