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
This commit is contained in:
parent
38b3f75243
commit
7d3ef26558
@ -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<Node, Void>` values.)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
26
Sources/XSLT/XSLTMatchCache.swift
Normal file
26
Sources/XSLT/XSLTMatchCache.swift
Normal file
@ -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<ObjectIdentifier>] = [:]
|
||||
|
||||
/// 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<ObjectIdentifier> {
|
||||
if let cached = matched[pattern] { return cached }
|
||||
var result: Set<ObjectIdentifier> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
30
Tests/XSLTMatchCacheTests.swift
Normal file
30
Tests/XSLTMatchCacheTests.swift
Normal file
@ -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) ? "<even>\(index)</even>" : "<odd>\(index)</odd>"
|
||||
}.joined()
|
||||
let source = "<r>\(rows)</r>"
|
||||
let stylesheet = """
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:output method="text"/>
|
||||
<xsl:template match="/"><xsl:apply-templates select="/r/*"/></xsl:template>
|
||||
<xsl:template match="even">E</xsl:template>
|
||||
<xsl:template match="odd | never">O</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user