fix(xpath): cache the attribute index for document-order keys (#305)
An attribute node's document-order key bands by the attribute's index among its
owner's attributes, found with owner.attributes.firstIndex { $0.name == name }.
That linear scan ran for every attribute node, so sorting an element's K
attributes into document order was O(K^2): an 8000-attribute element took ~0.18s
and grew quadratically.
Cache a per-owner attribute-name-to-index map in SiblingIndexCache, built once
on the second lookup (a single lookup still scans), exactly as the sibling-index
cache already does for child positions. The index is the same first-occurrence
position; only its cost changes.
The 8000-attribute sort drops to ~0.01s (~16x) and is now linear. The full
suite, the W3C XPath and Apache Xalan corpora, the C14N suite (which orders
attributes byte-exactly), and the WASM build stay green; AttributeDocumentOrderTests
pins declaration order through the cached sort.
This commit is contained in:
parent
2abb130757
commit
d86e4effbe
@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sorting many attribute nodes from the same element into document order is no longer quadratic in the attribute count. An attribute's document-order key bands by its index among its owner's attributes, which was found by a linear scan of the attribute list on every attribute, so sorting an element's K attributes was O(K^2). The index now comes from a cached per-owner name-to-index map (built once on the second lookup, mirroring the sibling-index cache). The order is unchanged. Sorting the attributes of an 8000-attribute element drops from about 0.18 s to about 0.01 s (~16x), and the cost is now linear. Guarded by `AttributeDocumentOrderTests`.
|
||||
|
||||
- The `following` and `preceding` axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; `following` additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The step's node test is fused into the walk, so a node the test rejects is never copied out of the shared list (only the matches are materialized). The selected nodes and their order are unchanged. A 200-context `following::` query over a 20k-element document drops from about 16 s to about 6 s. Guarded by `FollowingPrecedingAxisTests`.
|
||||
|
||||
## [0.4.3] - 2026-06-25
|
||||
|
||||
@ -8,6 +8,31 @@ extension PureXML.XPath {
|
||||
final class SiblingIndexCache {
|
||||
private var indexes: [ObjectIdentifier: [ObjectIdentifier: Int]] = [:]
|
||||
private var scannedOnce: Set<ObjectIdentifier> = []
|
||||
private var attributeIndexes: [ObjectIdentifier: [PureXML.Model.QualifiedName: Int]] = [:]
|
||||
private var attributeScannedOnce: Set<ObjectIdentifier> = []
|
||||
|
||||
/// The index of an attribute, by name, among its owner's attributes, for
|
||||
/// the attribute band of a document-order key. As with ``index(of:in:)``,
|
||||
/// the per-owner table is built on the second lookup (a single lookup
|
||||
/// scans), so sorting many attribute nodes from one element is linear
|
||||
/// rather than quadratic in the attribute count.
|
||||
func attributeIndex(of name: PureXML.Model.QualifiedName, in owner: PureXML.Model.TreeNode) -> Int {
|
||||
let ownerIdentity = ObjectIdentifier(owner)
|
||||
if let table = attributeIndexes[ownerIdentity] {
|
||||
return table[name] ?? 0
|
||||
}
|
||||
guard attributeScannedOnce.contains(ownerIdentity) else {
|
||||
attributeScannedOnce.insert(ownerIdentity)
|
||||
return owner.attributes.firstIndex { $0.name == name } ?? 0
|
||||
}
|
||||
var table: [PureXML.Model.QualifiedName: Int] = [:]
|
||||
table.reserveCapacity(owner.attributes.count)
|
||||
for (index, attribute) in owner.attributes.enumerated() where table[attribute.name] == nil {
|
||||
table[attribute.name] = index
|
||||
}
|
||||
attributeIndexes[ownerIdentity] = table
|
||||
return table[name] ?? 0
|
||||
}
|
||||
|
||||
func index(of child: PureXML.Model.TreeNode, in parent: PureXML.Model.TreeNode) -> Int {
|
||||
let parentIdentity = ObjectIdentifier(parent)
|
||||
|
||||
@ -132,7 +132,7 @@ public extension PureXML.XPath {
|
||||
case let .tree(node):
|
||||
return Self.treePath(node, cache: cache)
|
||||
case let .attribute(owner, attribute):
|
||||
let index = owner.attributes.firstIndex { $0.name == attribute.name } ?? 0
|
||||
let index = cache.attributeIndex(of: attribute.name, in: owner)
|
||||
return Self.treePath(owner, cache: cache) + [Self.attributeBand, index]
|
||||
case let .namespace(owner, prefix, _):
|
||||
return Self.treePath(owner, cache: cache) + [Self.namespaceBand, prefix.hashValue]
|
||||
|
||||
39
Tests/AttributeDocumentOrderTests.swift
Normal file
39
Tests/AttributeDocumentOrderTests.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// An attribute node's document-order key bands by its index among its owner's
|
||||
/// attributes. That index now comes from a cached per-owner name-to-index map
|
||||
/// (built once on the second lookup) rather than a linear scan per attribute,
|
||||
/// so sorting many attribute nodes from one element is linear, not quadratic.
|
||||
/// These pin that the order is unchanged: declaration order, for attributes
|
||||
/// handed to the sort out of order, including a many-attribute element.
|
||||
@Suite("attribute document order")
|
||||
struct AttributeDocumentOrderTests {
|
||||
private func values(_ path: String, _ xml: String) throws -> [String] {
|
||||
try PureXML.XPath.Query(path).evaluate(over: PureXML.parse(xml)).compactMap {
|
||||
if case let .attribute(attribute) = $0 { return attribute.value }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a union of attributes comes back in declaration order")
|
||||
func test_unionOrder() throws {
|
||||
let xml = #"<e a="1" b="2" c="3" d="4"/>"#
|
||||
// The union lists them out of declaration order; the result is the
|
||||
// deduplicated node-set in document (declaration) order.
|
||||
#expect(try values("//e/@c | //e/@a | //e/@d | //e/@b", xml) == ["1", "2", "3", "4"])
|
||||
}
|
||||
|
||||
@Test("attributes of a many-attribute element sort in declaration order")
|
||||
func test_manyAttributes() throws {
|
||||
// The quadratic-prone case: one element with many attributes, all sorted.
|
||||
var attrs = ""
|
||||
for index in 0 ..< 200 {
|
||||
attrs += " a\(index)=\"\(index)\""
|
||||
}
|
||||
let xml = "<e\(attrs)/>"
|
||||
let ordered = try values("//e/@* | //e/@*", xml)
|
||||
#expect(ordered.count == 200)
|
||||
#expect(ordered == (0 ..< 200).map(String.init))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user