PureXML/Tests/XPathNodeSetDedupTests.swift
Mihaela Mihaljevic 2eb3741848
perf(xpath): skip node-set de-duplication where no duplicate can arise (#280)
Each location step accumulated its context nodes' axis results into a
Set<Node> to drop nodes reachable from more than one context, hashing and
copying every node. The wrapped TreeNode is reference-counted, so each copy
is ARC traffic, and the profile showed the Set insert, hashing, and Node
copies dominating evaluation.

De-duplication is needed only when two distinct context nodes can reach the
same node. Skip it on a single context and on the axes whose results are
disjoint per context (child, attribute, namespace, self): those produce the
identical node sequence the Set would, since every insertion succeeds, with
no hashing. Keep the Set on the axes that genuinely overlap (descendant,
ancestor, parent, the siblings, following/preceding). Predicates stay applied
per context and the path result still passes through the final
order-and-unique pass, so results are identical.

Measured xpath ~13x to ~10x slower than libxml2 on the 20k-item generated
corpus via a same-state A/B, ~28% faster, same result. XPathNodeSetDedupTests
pins that a node shared by many contexts through ancestor/parent/descendant
is still counted once, the disjoint axes return every distinct result, and an
overlapping union still de-duplicates. The full XPath suite and the Apache
Xalan XSLT gold-output corpus stay green.
2026-06-25 01:33:50 +02:00

48 lines
2.1 KiB
Swift

import Testing
@testable import PureXML
/// `evaluateSteps` skips cross-context node-set de-duplication on axes whose
/// results are disjoint per context (child, attribute, namespace, self) or when
/// there is a single context, and keeps it on the axes that can reach one node
/// from two contexts (ancestor, descendant, parent, the siblings,
/// following/preceding). These tests pin both directions: a shared node reached
/// from many contexts must appear exactly once, and a disjoint axis must still
/// return every distinct result.
@Suite("XPath node-set de-duplication")
struct XPathNodeSetDedupTests {
/// Two subtrees, each holding a `c`, both under `root`; `a` holds two of them.
private func doc() throws -> PureXML.Model.TreeNode {
try PureXML.parseTree("<root><a><c/><c/></a><b><c/></b></root>")
}
private func count(_ path: String) throws -> Int {
try Int(PureXML.XPath.Query("count(\(path))").value(at: doc()).number)
}
@Test("ancestor axis de-duplicates a node shared by many contexts")
func test_ancestorDedup() throws {
// Three `c` nodes all have `root` as an ancestor; it must be counted once.
#expect(try count("//c/ancestor::root") == 1)
// Two `c` nodes under `a` share `a` as parent; `a` counted once.
#expect(try count("/root/a/c/parent::a") == 1)
// descendant from multiple contexts: every `c` reached once.
#expect(try count("/root/*/descendant-or-self::c") == 3)
}
@Test("disjoint axes return every distinct result")
func test_disjointAxesComplete() throws {
// child from multiple contexts: all three `c` children.
#expect(try count("/root/*/child::c") == 3)
// self from the three distinct `c` contexts: three nodes.
#expect(try count("//c/self::c") == 3)
// The whole node test still works under //.
#expect(try count("//c") == 3)
}
@Test("a union of overlapping paths still de-duplicates")
func test_unionDedup() throws {
// `//c` and `//a//c` overlap on the two `c` under `a`; union is 3, not 5.
#expect(try count("//c | //a//c") == 3)
}
}