diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d94b7..fdee6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The value-returning XPath result API (`PureXML.xpath`, `XPath.Query.evaluate`/`elements`/`strings`) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value `Node` subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value `Node` of a deeper one by reference (copy-on-write); each tree node is projected once. `//a` over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348) + - Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged. - Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345) diff --git a/Sources/Model/TreeNodeConversion.swift b/Sources/Model/TreeNodeConversion.swift index b16754e..e00f750 100644 --- a/Sources/Model/TreeNodeConversion.swift +++ b/Sources/Model/TreeNodeConversion.swift @@ -182,6 +182,85 @@ public extension PureXML.Model.TreeNode { parent.built.append(.document(frame.built)) } } + + /// Like ``node`` but reuses subtrees already projected during this pass through + /// `memo` (keyed by tree-node identity). Projecting several result nodes that + /// nest within one another then shares structure (copy-on-write) instead of + /// rebuilding each subtree, so a result set of nested nodes is linear rather + /// than quadratic. The projection is identical to ``node``. + func node(memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> PureXML.Model.Node { + if let cached = memo[ObjectIdentifier(self)] { return cached } + let result: PureXML.Model.Node = switch kind { + case .document: + .document(Self.projectedChildren(of: self, memo: &memo)) + case .element: + .element(PureXML.Model.Element( + name: name ?? PureXML.Model.QualifiedName(""), + attributes: attributes, + children: Self.projectedChildren(of: self, memo: &memo), + )) + case .text: .text(value) + case .entityReference: .text(stringValue) + case .cdata: .cdata(value) + case .comment: .comment(value) + case .processingInstruction: .processingInstruction(target: name?.description ?? "", data: value) + case .doctype, .namespace: .text("") + } + memo[ObjectIdentifier(self)] = result + return result + } + + /// ``projectedChildren(of:)`` that caches each element/document subtree in + /// `memo` and reuses an already-projected child instead of walking it again. + private static func projectedChildren(of root: PureXML.Model.TreeNode, memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> [PureXML.Model.Node] { + let rootFrame = ProjectFrame(root, splice: false) + var stack: [ProjectFrame] = [rootFrame] + while let frame = stack.last { + guard frame.next < frame.tree.children.count else { + stack.removeLast() + guard let parent = stack.last else { break } + finishProjection(frame, into: parent, memo: &memo) + continue + } + let child = frame.tree.children[frame.next] + frame.next += 1 + switch child.kind { + case .doctype, .namespace: + continue + case .entityReference: + stack.append(ProjectFrame(child, splice: true)) + case .element, .document: + if let cached = memo[ObjectIdentifier(child)] { + frame.built.append(cached) + } else { + stack.append(ProjectFrame(child, splice: false)) + } + default: + frame.built.append(projectLeaf(child)) + } + } + return rootFrame.built + } + + /// ``finishProjection(_:into:)`` that also records an element or document + /// frame's projected node in `memo` for reuse. + private static func finishProjection(_ frame: ProjectFrame, into parent: ProjectFrame, memo: inout [ObjectIdentifier: PureXML.Model.Node]) { + if frame.splice { + parent.built.append(contentsOf: frame.built) + return + } + let node: PureXML.Model.Node = if case .element = frame.tree.kind { + .element(PureXML.Model.Element( + name: frame.tree.name ?? PureXML.Model.QualifiedName(""), + attributes: frame.tree.attributes, + children: frame.built, + )) + } else { + .document(frame.built) + } + memo[ObjectIdentifier(frame.tree)] = node + parent.built.append(node) + } } /// A branch node being converted from a value ``Node`` to a ``TreeNode``: the diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index 0840f5f..e05c589 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -17,7 +17,7 @@ extension PureXML.XPath { guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else { return [] } - return orderUnique(nodes).map(selection) + return selections(orderUnique(nodes)) } /// Evaluates an expression to a typed value with optional variable bindings. @@ -103,7 +103,7 @@ extension PureXML.XPath { guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else { return [] } - return orderUnique(nodes).map(selection) + return selections(orderUnique(nodes)) } private static func rootContext( @@ -221,10 +221,24 @@ extension PureXML.XPath { return current } - private static func selection(_ node: Node) -> Selection { + /// Projects an ordered node-set to selections, sharing a projection memo + /// so that result nodes nesting within one another reuse each other's + /// already-built value `Node` subtrees rather than each rebuilding the + /// whole subtree (which is quadratic for nested results). + static func selections(_ nodes: [Node]) -> [Selection] { + var memo: [ObjectIdentifier: PureXML.Model.Node] = [:] + var result: [Selection] = [] + result.reserveCapacity(nodes.count) + for node in nodes { + result.append(selection(node, memo: &memo)) + } + return result + } + + private static func selection(_ node: Node, memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> Selection { switch node { case let .tree(tree): - return .node(tree.node) + return .node(tree.node(memo: &memo)) case let .attribute(_, attribute): return .attribute(attribute) case let .namespace(_, prefix, uri): diff --git a/Tests/XPathDocumentOrderScaleTests.swift b/Tests/XPathDocumentOrderScaleTests.swift index cb1069f..78ee89d 100644 --- a/Tests/XPathDocumentOrderScaleTests.swift +++ b/Tests/XPathDocumentOrderScaleTests.swift @@ -43,4 +43,38 @@ struct XPathDocumentOrderScaleTests { #expect(names == ["r", "a", "b", "c"]) #expect(strings.count == 4) } + + /// A value `Node` nesting `depth` `a` elements inside one another, as a document. + private func deepValueDocument(_ depth: Int) -> PureXML.Model.Node { + var node: PureXML.Model.Node = .text("leaf") + for _ in 0 ..< depth { + node = .element(.init("a", children: [node])) + } + return .document([node]) + } + + #if os(WASI) + @Test("the value-Node result API is linear for nested results") + #else + @Test("the value-Node result API is linear for nested results", .timeLimit(.minutes(1))) + #endif + func test_nestedValueResultsAreLinear() throws { + // `//a` over a deep chain selects every `a`, each containing all deeper + // ones. Without projection sharing, materializing them as value `Node`s is + // the sum of their subtree sizes, which is quadratic (#348). + let depth = 30000 + let document = deepValueDocument(depth) + let results = try PureXML.XPath.Query("//a").evaluate(over: document) + #expect(results.count == depth) + // The outermost result still carries the full nesting, proving the shared + // projection did not truncate any subtree: descending the first child from + // the outermost `a` reaches all `depth` `a` elements. + var element = results.first?.element + var reachable = 0 + while let current = element { + reachable += 1 + element = current.children.first?.element + } + #expect(reachable == depth) + } }