From d86e4effbe48391f9d4e35bc732dccfd20083732 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 22:01:13 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 ++ Sources/XPath/SiblingIndexCache.swift | 25 ++++++++++++++++ Sources/XPath/XPathNode.swift | 2 +- Tests/AttributeDocumentOrderTests.swift | 39 +++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 Tests/AttributeDocumentOrderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbecf1..8412843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/XPath/SiblingIndexCache.swift b/Sources/XPath/SiblingIndexCache.swift index f01b774..600f705 100644 --- a/Sources/XPath/SiblingIndexCache.swift +++ b/Sources/XPath/SiblingIndexCache.swift @@ -8,6 +8,31 @@ extension PureXML.XPath { final class SiblingIndexCache { private var indexes: [ObjectIdentifier: [ObjectIdentifier: Int]] = [:] private var scannedOnce: Set = [] + private var attributeIndexes: [ObjectIdentifier: [PureXML.Model.QualifiedName: Int]] = [:] + private var attributeScannedOnce: Set = [] + + /// 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) diff --git a/Sources/XPath/XPathNode.swift b/Sources/XPath/XPathNode.swift index 714b499..0a5f96c 100644 --- a/Sources/XPath/XPathNode.swift +++ b/Sources/XPath/XPathNode.swift @@ -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] diff --git a/Tests/AttributeDocumentOrderTests.swift b/Tests/AttributeDocumentOrderTests.swift new file mode 100644 index 0000000..6be7061 --- /dev/null +++ b/Tests/AttributeDocumentOrderTests.swift @@ -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 = #""# + // 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 = "" + let ordered = try values("//e/@* | //e/@*", xml) + #expect(ordered.count == 200) + #expect(ordered == (0 ..< 200).map(String.init)) + } +}