From 7d3ef2655852c9f733005bf7a00513f69e5aa5c8 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 03:24:38 +0200 Subject: [PATCH] perf(xpath): compile patterns once per run, not per node XSLT: template selection resolves each match pattern once per transform via a per-run MatchCache (the pattern's node set is computed once over the source tree; selection becomes an identity lookup), replacing per-(node, template) recompilation and full-tree re-walks. XSD identity validation: a per-run query cache compiles each selector/field XPath once instead of at every visited element. Schematron was verified to already store compiled queries. Behavior unchanged (all suites green); a multi-template shape test over 300 nodes guards the optimized path. MatchCache lives in its own file to keep the transformer under the length cap. Closes #112 --- CHANGELOG.md | 1 + README.md | 2 +- Sources/Schema/IdentityValidator.swift | 24 +++++++++++++++++++-- Sources/XSLT/XSLTMatchCache.swift | 26 ++++++++++++++++++++++ Sources/XSLT/XSLTTransformer.swift | 11 +++------- Tests/XSLTMatchCacheTests.swift | 30 ++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 Sources/XSLT/XSLTMatchCache.swift create mode 100644 Tests/XSLTMatchCacheTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3d4cb..711d5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path. - The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees. - DTD attribute validation is now decomposed into five named, independently composable `Validation` rules (#101): `DTD.requiredAttributes`, `fixedAttributeValues`, `enumeratedAttributeValues`, `tokenizedAttributeTypes`, and `notationAttributes`, replacing the single `attributeDeclarations` rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable `Validation` values.) diff --git a/README.md b/README.md index d013e37..42345e8 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ flowchart TB PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite -currently runs **824 tests in 130 suites** (`swift test`). +currently runs **825 tests in 131 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 2d3d45e..3c2c5bc 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -1,3 +1,17 @@ +/// Compiles each distinct XPath string once per validation run, so a +/// constraint's selector and field queries are not re-parsed at every element +/// the walk visits. A string that fails to compile caches as nil (no match). +private final class XPathQueryCache { + private var queries: [String: PureXML.XPath.Query?] = [:] + + func query(_ xpath: String) -> PureXML.XPath.Query? { + if let cached = queries[xpath] { return cached } + let compiled = try? PureXML.XPath.Query(xpath) + queries[xpath] = compiled + return compiled + } +} + extension PureXML.Schema { /// Validates XSD identity constraints (`unique`, `key`, `keyref`) over an /// instance document. Each constraint is evaluated at every element whose @@ -8,6 +22,12 @@ extension PureXML.Schema { struct IdentityValidator { /// The constraints declared for each element local name. let constraints: [String: [IdentityConstraint]] + /// Selector and field XPaths compiled once per run, not per element. + private let cache = XPathQueryCache() + + init(constraints: [String: [IdentityConstraint]]) { + self.constraints = constraints + } func validate( _ root: PureXML.Model.TreeNode, @@ -162,13 +182,13 @@ extension PureXML.Schema { // MARK: XPath helpers private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { - guard let query = try? PureXML.XPath.Query(xpath) else { return [] } + guard let query = cache.query(xpath) else { return [] } return query.nodes(over: node) } private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode) -> [String?] { fields.map { field in - guard let query = try? PureXML.XPath.Query(field), let value = try? query.value(at: node) else { + guard let query = cache.query(field), let value = try? query.value(at: node) else { return nil } if let nodes = value.nodes, nodes.isEmpty { return nil } diff --git a/Sources/XSLT/XSLTMatchCache.swift b/Sources/XSLT/XSLTMatchCache.swift new file mode 100644 index 0000000..54a4136 --- /dev/null +++ b/Sources/XSLT/XSLTMatchCache.swift @@ -0,0 +1,26 @@ +extension PureXML.XSLT { + /// Caches, per transform, the node set each match pattern selects over the + /// source tree, so a pattern is compiled and evaluated once per document + /// rather than once per (node, template) pair during template selection. + final class MatchCache { + private var matched: [String: Set] = [:] + + /// The identities of the nodes `pattern` selects over `root`, computed and + /// cached on first use. A branch that fails to compile selects nothing. + func nodes(matching pattern: String, over root: PureXML.Model.TreeNode) -> Set { + if let cached = matched[pattern] { return cached } + var result: Set = [] + for branch in pattern.split(separator: "|") { + let trimmed = branch.trimmingXMLWhitespace() + let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed + if let query = try? PureXML.XPath.Query(path) { + for node in query.nodes(over: root) { + result.insert(ObjectIdentifier(node)) + } + } + } + matched[pattern] = result + return result + } + } +} diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index ba5dd3c..97ad8b3 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -3,6 +3,7 @@ private typealias ResultItem = PureXML.XSLT.ResultItem private typealias XSLTContext = PureXML.XSLT.XSLTContext private typealias Termination = PureXML.XSLT.Termination +private typealias MatchCache = PureXML.XSLT.MatchCache extension PureXML.XSLT { /// Runs a compiled stylesheet against a source tree, producing a result tree @@ -15,6 +16,7 @@ extension PureXML.XSLT { let documentLoader: (String) -> String? private let keyIndex: PureXML.XSLT.KeyIndex private let termination = Termination() + private let matchCache = MatchCache() /// The `xsl:message terminate="yes"` text, if one fired during `run()`. var terminationMessage: String? { @@ -51,14 +53,7 @@ extension PureXML.XSLT { } private func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool { - for branch in pattern.split(separator: "|") { - let trimmed = branch.trimmingXMLWhitespace() - let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed - if let query = try? PureXML.XPath.Query(path), query.nodes(over: root).contains(where: { $0 === node }) { - return true - } - } - return false + matchCache.nodes(matching: pattern, over: root).contains(ObjectIdentifier(node)) } fileprivate func applyTemplates( diff --git a/Tests/XSLTMatchCacheTests.swift b/Tests/XSLTMatchCacheTests.swift new file mode 100644 index 0000000..ba7e098 --- /dev/null +++ b/Tests/XSLTMatchCacheTests.swift @@ -0,0 +1,30 @@ +@testable import PureXML +import Testing + +@Suite("XSLT match-pattern caching") +struct XSLTMatchCacheTests { + /// Guards the shape #112 optimized: many nodes against several templates, + /// where each match pattern must be compiled and evaluated once per + /// transform (not once per node) and selection must stay correct. + @Test("A multi-template transform over hundreds of nodes selects correctly") + func test_multiTemplateShape() throws { + let rows = (1 ... 300).map { index in + index.isMultiple(of: 2) ? "\(index)" : "\(index)" + }.joined() + let source = "\(rows)" + let stylesheet = """ + + + + E + O + + """ + let output = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source) + #expect(output.count == 300) + #expect(output.count(where: { $0 == "E" }) == 150) + #expect(output.count(where: { $0 == "O" }) == 150) + // Alternation is preserved (selection by pattern, not by order accident). + #expect(output.hasPrefix("OEOE")) + } +}