From 4552d240672a7a35b460ecf1e7f57191525acc3c Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 25 Jun 2026 18:45:08 +0200 Subject: [PATCH] fix(xslt): make key(), id(), and EXSLT set functions linear over wide fan-outs (#300) key(), id(), and EXSLT set:leading/set:trailing sorted their result with sorted(by: Node.precedes). precedes recomputes both nodes' document-order keys on every comparison, and each key walks the node to the root taking its index among each parent's children, a linear scan of a wide parent. Over a wide flat fan-out the sort was therefore quadratic. key() also deduplicated its matches with matched.contains(where: ===), a linear membership scan, O(m^2) in the match count. The EXSLT relative() used the cache-less documentOrder, rescanning the parent per node. Sort through sortedByDocumentOrder() (each key computed once behind a shared sibling-index cache), deduplicate key()'s matches through a Set, and give relative() a shared cache. The selected nodes, their document order, and the deduplication are unchanged. A key()-driven transform over a 20k-element document drops from ~1.15s to ~0.43s (~2.7x). The full suite, the Apache Xalan XSLT gold-output corpus, the W3C XPath/XSLT/EXSLT tests, and the WASM build stay green; XSLTKeyIdOrderTests pins key() dedup-and-order and id() document order. --- CHANGELOG.md | 2 ++ Sources/XPath/EXSLTFunctions.swift | 11 +++--- Sources/XSLT/XSLTFunctions.swift | 2 +- Sources/XSLT/XSLTLookups.swift | 7 ++-- Tests/XSLTKeyIdOrderTests.swift | 56 ++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 Tests/XSLTKeyIdOrderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index be9fcd5..358b1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `key()`, `id()`, and the EXSLT `set:leading`/`set:trailing` functions no longer rescan a node's siblings per comparison, which made them quadratic over a wide flat fan-out. They sorted their result with `sorted(by: Node.precedes)`, which recomputes both nodes' document-order keys on every comparison (each key a linear scan of a wide parent), and `key()` additionally deduplicated its matches with a linear membership scan. They now sort through `sortedByDocumentOrder()` (each key computed once, behind a shared sibling-index cache) and `key()` deduplicates through a set. The selected nodes, their document order, and the deduplication are unchanged. A `key()`-driven transform over a 20k-element document drops from about 1.15 s to about 0.43 s (~2.7x); the Apache Xalan gold-output corpus stays byte-identical. Guarded by `XSLTKeyIdOrderTests`. + - Extracting the string-value of a node-set no longer scans the node's siblings, which made an XSLT transform (or any repeated `string(@x)` / `value-of`) over a wide flat fan-out quadratic. `firstInDocumentOrder()` computed a document-order key even for a zero- or one-node set, and that key walks the node up to the root taking its index among each parent's children, a linear scan of a wide parent. For a single node the order is irrelevant (the node is trivially first), so it now returns directly without computing the key. A transform that reads a couple of attributes and child values per element over a 20k-element document drops from about 2.3 s to about 0.65 s (~3.5x); the produced output is unchanged (the Apache Xalan gold-output corpus stays byte-identical). Guarded by `FirstInDocumentOrderTests`. ## [0.4.2] - 2026-06-25 diff --git a/Sources/XPath/EXSLTFunctions.swift b/Sources/XPath/EXSLTFunctions.swift index c95e060..14c9ad3 100644 --- a/Sources/XPath/EXSLTFunctions.swift +++ b/Sources/XPath/EXSLTFunctions.swift @@ -114,11 +114,14 @@ extension PureXML.XPath { guard let pivot = (arguments.count > 1 ? arguments[1].nodes ?? [] : []).firstInDocumentOrder() else { return [] } - // Decorate once: each node's order key is computed a single time and - // reused for both the pivot comparison and the final sort. - let pivotKey = pivot.documentOrder + // Decorate once through a shared sibling-index cache: each node's + // order key is computed a single time, and the per-parent index is + // built once rather than rescanned for every node (which made this + // quadratic over a wide fan-out). + let cache = PureXML.XPath.SiblingIndexCache() + let pivotKey = pivot.documentOrder(cache: cache) return first - .map { (node: $0, key: $0.documentOrder) } + .map { (node: $0, key: $0.documentOrder(cache: cache)) } .filter { leading ? Node.ordered($0.key, before: pivotKey) : Node.ordered(pivotKey, before: $0.key) } .sorted { Node.ordered($0.key, before: $1.key) } .map(\.node) diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 7e20a09..2483d89 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -221,7 +221,7 @@ extension PureXML.XSLT { // without a second argument, against the reference node's own // base (node-set) or the stylesheet (string). let secondBase: String? = arguments.count > 1 - ? (arguments[1].nodes?.min(by: PureXML.XPath.Node.precedes)).map { baseURI(of: $0, documents) } + ? arguments[1].nodes?.firstInDocumentOrder().map { baseURI(of: $0, documents) } : nil func load(_ reference: String, _ base: String) -> [PureXML.XPath.Node] { documentReference(resolveDocumentReference(reference, against: base), loader, documents, selfDocument) diff --git a/Sources/XSLT/XSLTLookups.swift b/Sources/XSLT/XSLTLookups.swift index 02304ae..a3725a7 100644 --- a/Sources/XSLT/XSLTLookups.swift +++ b/Sources/XSLT/XSLTLookups.swift @@ -102,7 +102,7 @@ extension PureXML.XSLT.Library { } let matched = index.filter { tokens.contains($0.key) }.values .map { PureXML.XPath.Node.tree($0) } - .sorted(by: PureXML.XPath.Node.precedes) + .sortedByDocumentOrder() return .nodeSet(matched) } @@ -150,11 +150,12 @@ extension PureXML.XSLT.Library { } let index = keys(documentRoot) var matched: [PureXML.Model.TreeNode] = [] + var seen: Set = [] for value in values { - for node in index[name]?[value] ?? [] where !matched.contains(where: { $0 === node }) { + for node in index[name]?[value] ?? [] where seen.insert(ObjectIdentifier(node)).inserted { matched.append(node) } } - return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sorted(by: PureXML.XPath.Node.precedes)) + return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sortedByDocumentOrder()) } } diff --git a/Tests/XSLTKeyIdOrderTests.swift b/Tests/XSLTKeyIdOrderTests.swift new file mode 100644 index 0000000..61c5485 --- /dev/null +++ b/Tests/XSLTKeyIdOrderTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import PureXML + +/// `key()`, `id()`, and the EXSLT `set:leading`/`set:trailing` functions return +/// their nodes in document order. The order is now computed through the cached +/// `sortedByDocumentOrder`/`documentOrder(cache:)` path (not `sorted(by:precedes)` +/// or the cache-less key, which rescanned a wide parent per comparison), and +/// `key()` deduplicates through a set, not a linear membership scan. These pin +/// that the results are unchanged: the right nodes, in document order, once each. +@Suite("XSLT key/id/set document order and dedup") +struct XSLTKeyIdOrderTests { + @Test("key() returns its matches in document order, deduplicated") + func test_keyDedupAndOrder() throws { + // Several values map to overlapping nodes; the node-set second argument + // unions them and each matching node must appear once, in document order. + let source = """ + + 12345 + aba + + """ + let style = """ + + + + + , + + + """ + let out = try PureXML.XSLT.transform(stylesheet: style, source: source) + // k='a' -> nodes 1,3; k='b' -> nodes 2,5. Union, deduplicated, in + // document order: 1,2,3,5. + #expect(out.hasSuffix("1,2,3,5,")) + } + + @Test("id() with several tokens returns the elements in document order") + func test_idOrder() throws { + let source = """ + ]> + CAB + """ + let style = """ + + + + + + + """ + let out = try PureXML.XSLT.transform(stylesheet: style, source: source) + // id() returns the referenced elements in document order regardless of the + // token order: C, A, B as they appear. + #expect(out.hasSuffix("CAB")) + } +}