Skip to content

Commit 85ac579

Browse files
authored
Fix document order in REXML::XPathParser.sort (#356)
`REXML::XPathParser.sort` keys each node on its index under each ancestor, but two different nodes could end up with the same key, and ties are then broken arbitrarily by an unstable sort. The walk stopped at the root element, so the root and everything outside it -- comments and PIs at document level -- all keyed on an empty array: ``` <!--c1--><?pi1?><root/><!--c2--><?pi2?> ``` //comment() | //processing-instruction() was: c1, c2, pi1, pi2 want: c1, pi1, c2, pi2 Walk up to the document instead, so those nodes are ordered against each other. An attribute borrowed the key of the element carrying it, so the element and its attributes tied: ``` <root><a x="1" y="2"><b/></a></root> ``` //a/@* | //a | //a/* was: @x, @y, <a>, <b> want: <a>, @x, @y, <b> Extend an attribute's key past its element's with ATTRIBUTE_POSITION. A child index is never negative, so -1 lands the attributes after `<a>` and ahead of `<b>`, which is where document order wants them. The attributes of one element tied with each other too, which went unnoticed because a small enough sort leaves its input alone: <a z="1" m="2" b="3"/> //a/@* -> z, m, b <a z="1" ... 26 attributes/> //a/@* -> scrambled XPath 1.0 leaves their relative order implementation dependent, but document order is a total ordering, so it has to be decided. Key them on where they were written, which is the order the small case already appeared to have. Finding that out means walking the attribute list, so index the whole list at once and remember it for the rest of the sort; asking per attribute would make sorting quadratic in the number of attributes an element carries, which is something a document gets to choose. The ancestor walk becomes a method of its own, since both branches of the key need it. It, attribute_position and ATTRIBUTE_POSITION are private rather than public names marked :nodoc:, sort being the only part callers outside the class use.
1 parent 9694327 commit 85ac579

2 files changed

Lines changed: 143 additions & 13 deletions

File tree

lib/rexml/xpath_parser.rb

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,11 @@ def leave(tag, *args)
804804
trace(:leave, tag, *args)
805805
end
806806

807+
# Sorts before any real child index, so that the attributes of an element
808+
# come after the element itself but before its children.
809+
ATTRIBUTE_POSITION = -1
810+
private_constant :ATTRIBUTE_POSITION
811+
807812
# Reorders an array of nodes so that they are in document order
808813
# It tries to do this efficiently.
809814
#
@@ -815,23 +820,52 @@ def leave(tag, *args)
815820
def self.sort(array_of_nodes)
816821
return array_of_nodes if array_of_nodes.size <= 1
817822

818-
new_arry = []
819-
array_of_nodes.each { |node|
820-
node_idx = []
821-
np = node.node_type == :attribute ? node.element : node
822-
while np.parent and np.parent.node_type == :element
823-
node_idx << np.parent.index( np )
824-
np = np.parent
823+
attribute_positions = {}.compare_by_identity
824+
array_of_nodes.sort_by do |node|
825+
if node.node_type == :attribute
826+
# An attribute has no place of its own in the child tree, so its key
827+
# extends that of the element carrying it.
828+
ancestor_indexes(node.element) <<
829+
ATTRIBUTE_POSITION << attribute_position(node, attribute_positions)
830+
else
831+
ancestor_indexes(node)
825832
end
826-
new_arry << [ node_idx.reverse, node ]
827-
}
828-
ordered = new_arry.sort_by do |index, node|
829-
index
830833
end
831-
ordered.collect do |_index, node|
832-
node
834+
end
835+
836+
# The index the node holds under each of its ancestors, outermost first.
837+
def self.ancestor_indexes(node)
838+
indexes = []
839+
# Walk all the way up to the document. Stopping at the root element
840+
# would leave every node outside it, and the root itself, with the same
841+
# empty key, and ties are then broken arbitrarily.
842+
while (parent = node.parent)
843+
indexes << parent.index(node)
844+
node = parent
845+
end
846+
indexes.reverse!
847+
end
848+
private_class_method :ancestor_indexes
849+
850+
# Where the attribute sits among the attributes of its element. XPath 1.0
851+
# leaves the relative order of those implementation dependent, but document
852+
# order is a total ordering, so they do need one; this keeps them in the
853+
# order they were written in.
854+
def self.attribute_position(attribute, positions)
855+
position = positions[attribute]
856+
return position if position
857+
858+
# Index the whole attribute list at once. A node set often holds every
859+
# attribute of an element, and looking each one up on its own would make
860+
# sorting quadratic in the number of attributes.
861+
i = 0
862+
attribute.element.attributes.each_attribute do |other|
863+
positions[other] ||= i
864+
i += 1
833865
end
866+
positions[attribute]
834867
end
868+
private_class_method :attribute_position
835869

836870
# Scanner for descendant-or-self axis
837871
def descendant_or_self(nodeset, tester, selector)

test/xpath/test_base.rb

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# frozen_string_literal: false
22

3+
require "core_assertions"
4+
35
module REXMLTests
46
class TestXPathBase < Test::Unit::TestCase
57
include Helper::Fixture
8+
include Test::Unit::CoreAssertions
69
include REXML
710
SOURCE = <<-EOF
811
<a id='1'>
@@ -1564,6 +1567,83 @@ def test_reverse_axis_function_argument_sort
15641567
assert_equal(["e"], XPath.match(doc, "//e[preceding-sibling::* = '1']").map(&:name))
15651568
end
15661569

1570+
def test_document_order_top_level_nodes
1571+
# Nodes outside the root element are still ordered against each other.
1572+
doc = Document.new("<!--c1--><?pi1?><root/><!--c2--><?pi2?>")
1573+
nodes = XPath.match(doc, "//comment() | //processing-instruction()")
1574+
assert_equal(["c1", "pi1", "c2", "pi2"], stringify_nodes(nodes))
1575+
end
1576+
1577+
def test_document_order_descendant_or_self_from_document
1578+
doc = Document.new("<!--c1--><root><a/></root><!--c2-->")
1579+
nodes = XPath.match(doc, "/descendant-or-self::node()")
1580+
assert_equal(["DOC", "c1", "root", "a", "c2"], stringify_nodes(nodes))
1581+
end
1582+
1583+
# The relative order of attributes of one element is implementation
1584+
# dependent, but they must all come after the element that carries them,
1585+
# whichever way round the union is written.
1586+
def test_document_order_element_precedes_its_attributes
1587+
doc = Document.new("<root><a x='1' y='2'/></root>")
1588+
nodes = XPath.match(doc, "//a | //a/@*")
1589+
assert_equal("a", nodes.first.name)
1590+
assert_equal(["x", "y"], nodes[1..-1].collect(&:name).sort)
1591+
end
1592+
1593+
def test_document_order_element_precedes_its_attributes_reversed_union
1594+
doc = Document.new("<root><a x='1' y='2'/></root>")
1595+
nodes = XPath.match(doc, "//a/@* | //a")
1596+
assert_equal("a", nodes.first.name)
1597+
assert_equal(["x", "y"], nodes[1..-1].collect(&:name).sort)
1598+
end
1599+
1600+
def test_document_order_attributes_of_one_element
1601+
# XPath 1.0 leaves the relative order of the attributes of one element
1602+
# implementation dependent, but document order is a total ordering, so it
1603+
# still has to be decided: they come out in the order they were written.
1604+
names = ("a".."z").to_a.reverse
1605+
# Give each attribute its own value, so that string(), which takes the
1606+
# first node in document order, tells the order apart too.
1607+
attributes = names.collect {|name| "#{name}='#{name}'" }.join(" ")
1608+
doc = Document.new("<root><a #{attributes}/></root>")
1609+
assert_equal(names, XPath.match(doc, "//a/@*").collect(&:name))
1610+
assert_equal("z", XPath.match(doc, "string(//a/@*)").first)
1611+
end
1612+
1613+
def test_linear_performance_sort_attributes_of_one_element
1614+
# Ordering the attributes of an element must not cost more than the
1615+
# attributes themselves: one whole list is indexed per element, not one
1616+
# list per attribute.
1617+
omit("This is fragile on JRuby") if RUBY_ENGINE == "jruby"
1618+
seq = [1000, 5000, 10000, 20000, 40000]
1619+
build = ->(n) {
1620+
attributes = n.times.collect {|i| "a#{i}='1'" }.join(" ")
1621+
Document.new("<root><a #{attributes}/></root>")
1622+
}
1623+
assert_linear_performance(seq, rehearsal: 10, pre: build) do |doc|
1624+
XPath.match(doc, "//a/@*")
1625+
end
1626+
end
1627+
1628+
def test_document_order_attribute_axis_across_elements
1629+
# Attributes of different elements are ordered by their owning elements.
1630+
doc = Document.new("<root><a id='1'/><a id='2'/><a id='3'/></root>")
1631+
assert_equal(["1", "2", "3"], XPath.match(doc, "//a/@id").collect(&:value))
1632+
end
1633+
1634+
def test_document_order_mixed_text_and_element_children
1635+
source = <<-XML
1636+
<root>
1637+
<a>before0<b/>after0</a>
1638+
<a>before1<c/>after1</a>
1639+
</root>
1640+
XML
1641+
doc = Document.new(source)
1642+
nodes = XPath.match(doc, "//a/node()")
1643+
assert_equal(["before0", "b", "after0", "before1", "c", "after1"],
1644+
stringify_nodes(nodes))
1645+
end
1646+
15671647
def test_unimplemented_id_should_not_contaminate_nil
15681648
doc = Document.new("<root/>")
15691649
assert_equal([], XPath.match(doc, 'id("foo")'))
@@ -1607,5 +1687,21 @@ def test_variables_invalid_predicates
16071687
actual = (XPath.match(doc, '($x)[1<2]', nil, { 'x' => 42 }) rescue :exception)
16081688
assert_includes(valid_result, actual)
16091689
end
1690+
1691+
private
1692+
1693+
# Stringifies each node of a node set, whichever kind of node it is, so
1694+
# that document order can be asserted on a set that mixes them.
1695+
def stringify_nodes(nodes)
1696+
nodes.collect do |node|
1697+
case node.node_type
1698+
when :document then "DOC"
1699+
when :comment then node.string
1700+
when :processing_instruction then node.target
1701+
when :text then node.value
1702+
else node.name
1703+
end
1704+
end
1705+
end
16101706
end
16111707
end

0 commit comments

Comments
 (0)