perf(schema): make xsd identity-constraint validation linear (#312)
xs:unique/xs:key/xs:keyref were quadratic over a wide list of targets. The duplicate check compared each value tuple against every tuple already seen (and a keyref against every referenced key), and the error-location helper rescanned the parent's whole child list to position each target. Key tuples in a Set by their raw string when field equality provably reduces to a raw string comparison (whitespace-preserving lexical types, no colon, which is most ids); value-space types (numeric, boolean, date), whitespace-collapsing types, and QName-bearing values keep the exact pairwise comparison, so which documents pass is unchanged. Position each target through a per-parent, per-name index built once. A 16000-item xs:unique document drops from about 4.0 s to about 0.12 s (~34x), and the cost is now linear. The full local suite and the W3C XSTS datatype/identity conformance corpus are unchanged.
This commit is contained in:
parent
e080876eac
commit
d1a371a6a9
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- XSD identity-constraint validation (`xs:unique`, `xs:key`, `xs:keyref`) is no longer quadratic over a wide list of targets. Two scans drove the cost: the duplicate check compared each new value tuple against every tuple already seen (a `keyref` likewise scanned every referenced key), and the error-location helper rescanned the parent's whole child list to position each target. The first now keys tuples in a `Set` by their raw string, which is sound because field equality reduces to a raw `string == string` comparison for whitespace-preserving lexical types (most ids); value-space types (numeric, boolean, date), whitespace-collapsing types, and QName-bearing values keep the exact pairwise comparison, so which documents pass is unchanged. The second positions each target through a per-parent, per-name index built once. Validating a 16000-item document against an `xs:unique` drops from about 4.0 s to about 0.12 s (~34x), and the cost is now linear. Guarded by `SchemaIdentityScaleDuplicateTests`.
|
||||
|
||||
## [0.4.6] - 2026-06-26
|
||||
|
||||
### Fixed
|
||||
|
||||
57
Sources/Schema/IdentityTupleSet.swift
Normal file
57
Sources/Schema/IdentityTupleSet.swift
Normal file
@ -0,0 +1,57 @@
|
||||
/// A membership set for identity-constraint value tuples that detects an equal
|
||||
/// tuple in (amortized) O(1) for the common case instead of scanning every
|
||||
/// stored tuple, which made `unique`/`key`/`keyref` validation quadratic over a
|
||||
/// wide list (one duplicate scan per target, each linear in the targets seen).
|
||||
///
|
||||
/// `FieldValue` equality is value-space aware (`3.0` equals `3`, `true` equals
|
||||
/// `1`, a QName equals an equivalent prefix), so it cannot be hashed directly: a
|
||||
/// consistent hash would have to canonicalize each value space, and the QName
|
||||
/// case even depends on the other operand. Instead the set keys only the tuples
|
||||
/// it can prove compare as raw strings, and keeps the exact pairwise comparison
|
||||
/// for the rest, so behavior is identical to the former linear scan.
|
||||
///
|
||||
/// A field compares as a raw string when it is type-less (lexical equality) or
|
||||
/// an atomic, lexically-compared, whitespace-preserving type (so `valueMatches`
|
||||
/// reduces to `string == string`), and carries no colon that could trigger QName
|
||||
/// resolution. Two such tuples are equal exactly when their raw strings match,
|
||||
/// so they live in a `Set` keyed by those strings. Tuples with any value-space,
|
||||
/// whitespace-collapsing, or QName-bearing field are kept verbatim and compared
|
||||
/// with `==`; a raw-string query still scans them, and a value-space query scans
|
||||
/// everything, so no equal pair is ever missed.
|
||||
struct TupleSet {
|
||||
private var rawKeys: Set<[String]> = []
|
||||
private var unsafe: [[FieldValue?]] = []
|
||||
/// Every inserted tuple, consulted only when a value-space query must be
|
||||
/// compared against the raw-string-keyed tuples too.
|
||||
private var all: [[FieldValue?]] = []
|
||||
|
||||
/// Whether a stored tuple is equal to `tuple` under `FieldValue` equality.
|
||||
func contains(_ tuple: [FieldValue?]) -> Bool {
|
||||
if let key = Self.rawKey(of: tuple) {
|
||||
return rawKeys.contains(key) || unsafe.contains { $0 == tuple }
|
||||
}
|
||||
return all.contains { $0 == tuple }
|
||||
}
|
||||
|
||||
mutating func insert(_ tuple: [FieldValue?]) {
|
||||
if let key = Self.rawKey(of: tuple) {
|
||||
rawKeys.insert(key)
|
||||
} else {
|
||||
unsafe.append(tuple)
|
||||
}
|
||||
all.append(tuple)
|
||||
}
|
||||
|
||||
/// The raw-string key of a tuple every one of whose fields compares as a raw
|
||||
/// string, or nil when any field needs the value-space comparison.
|
||||
private static func rawKey(of tuple: [FieldValue?]) -> [String]? {
|
||||
var key: [String] = []
|
||||
key.reserveCapacity(tuple.count)
|
||||
for field in tuple {
|
||||
guard let field, !field.string.contains(":") else { return nil }
|
||||
if let type = field.type, !type.comparesRawLexically { return nil }
|
||||
key.append(field.string)
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,12 @@ extension PureXML.Schema {
|
||||
let types: [String: ElementType]
|
||||
/// Selector and field XPaths compiled once per run, not per element.
|
||||
private let cache = XPathQueryCache()
|
||||
/// Same-name sibling positions for error-location paths, indexed once per
|
||||
/// parent and name. ``steps`` looks a target's position up here instead of
|
||||
/// rescanning the parent's children every call, which made locating each
|
||||
/// of a wide list's targets O(n) and the whole `unique`/`key`/`keyref`
|
||||
/// pass quadratic.
|
||||
let stepIndex = StepSiblingIndex()
|
||||
|
||||
init(
|
||||
constraints: [String: [IdentityConstraint]],
|
||||
@ -115,11 +121,6 @@ extension PureXML.Schema {
|
||||
return []
|
||||
}
|
||||
|
||||
private func isNonRef(_ constraint: IdentityConstraint) -> Bool {
|
||||
if case .keyref = constraint.kind { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func collect(
|
||||
_ constraint: IdentityConstraint,
|
||||
at node: PureXML.Model.TreeNode,
|
||||
@ -129,7 +130,7 @@ extension PureXML.Schema {
|
||||
) {
|
||||
guard isNonRef(constraint) else { return }
|
||||
var tuples: [[FieldValue?]] = []
|
||||
var seen: [[FieldValue?]] = []
|
||||
var seen = TupleSet()
|
||||
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
|
||||
let targetPath = path + steps(from: node, to: target)
|
||||
if let error = fieldError(constraint, at: target, path: targetPath) {
|
||||
@ -145,13 +146,13 @@ extension PureXML.Schema {
|
||||
continue
|
||||
}
|
||||
if tuple.contains(where: { $0 == nil }) { continue }
|
||||
if seen.contains(where: { $0 == tuple }) {
|
||||
if seen.contains(tuple) {
|
||||
issues.append(.init(
|
||||
reason: "\(label(constraint)) '\(constraint.name)': duplicate value",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
))
|
||||
} else {
|
||||
seen.append(tuple)
|
||||
seen.insert(tuple)
|
||||
}
|
||||
tuples.append(tuple)
|
||||
}
|
||||
@ -167,6 +168,10 @@ extension PureXML.Schema {
|
||||
) {
|
||||
guard case let .keyref(refer) = constraint.kind else { return }
|
||||
let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
|
||||
var keys = TupleSet()
|
||||
for keyTuple in keyTuples {
|
||||
keys.insert(keyTuple)
|
||||
}
|
||||
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
|
||||
let targetPath = path + steps(from: node, to: target)
|
||||
if let error = fieldError(constraint, at: target, path: targetPath) {
|
||||
@ -175,7 +180,7 @@ extension PureXML.Schema {
|
||||
}
|
||||
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
||||
if tuple.contains(where: { $0 == nil }) { continue }
|
||||
if !keyTuples.contains(where: { $0 == tuple }) {
|
||||
if !keys.contains(tuple) {
|
||||
issues.append(.init(
|
||||
reason: "keyref '\(constraint.name)': no matching key '\(refer)'",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
@ -330,6 +335,13 @@ extension PureXML.Schema {
|
||||
}
|
||||
|
||||
extension PureXML.Schema.IdentityValidator {
|
||||
/// Whether a constraint collects tuples (`key`/`unique`) rather than
|
||||
/// referencing them (`keyref`).
|
||||
func isNonRef(_ constraint: PureXML.Schema.IdentityConstraint) -> Bool {
|
||||
if case .keyref = constraint.kind { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// The coding-path steps from the constraint-bearing `container` down to a
|
||||
/// selected `target`, so an identity error locates the actual offending
|
||||
/// element rather than the element that merely declares the constraint.
|
||||
@ -346,9 +358,8 @@ extension PureXML.Schema.IdentityValidator {
|
||||
var parent = container
|
||||
for node in chain.reversed() {
|
||||
let name = node.name?.description ?? ""
|
||||
let siblings = parent.children.filter { $0.kind == .element && ($0.name?.description ?? "") == name }
|
||||
if siblings.count > 1, let index = siblings.firstIndex(where: { $0 === node }) {
|
||||
steps.append(.element(name, index: index + 1))
|
||||
if let index = stepIndex.position(of: node, under: parent, name: name) {
|
||||
steps.append(.element(name, index: index))
|
||||
} else {
|
||||
steps.append(.element(name))
|
||||
}
|
||||
|
||||
@ -40,6 +40,18 @@ extension PureXML.Schema {
|
||||
stringValidity(value) ?? numericValidity(value)
|
||||
}
|
||||
|
||||
/// Whether two valid values of this primitive compare by their
|
||||
/// whitespace-processed lexical form rather than by a value space. These
|
||||
/// are exactly the `default` cases of `SimpleType.atomicValueMatches`:
|
||||
/// every primitive except the numeric, date, and boolean spaces, which
|
||||
/// compare two distinct lexical forms (`3.0`/`3`, `true`/`1`) as equal.
|
||||
var comparesLexically: Bool {
|
||||
switch self {
|
||||
case .decimal, .integer, .double, .float, .boolean, .dateKind: false
|
||||
default: true
|
||||
}
|
||||
}
|
||||
|
||||
/// Validity for the string-like and boolean spaces; nil when this primitive
|
||||
/// is handled by ``numericValidity(_:)``.
|
||||
private func stringValidity(_ value: String) -> Bool? {
|
||||
|
||||
@ -137,6 +137,20 @@ public extension PureXML.Schema {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether two values of this type are equal exactly when their raw
|
||||
/// strings match: an atomic, lexically-compared type whose effective
|
||||
/// `whiteSpace` is `preserve`, so `valueMatches` reduces `process` to the
|
||||
/// identity and compares the strings verbatim. Identity-constraint
|
||||
/// duplicate detection keys such fields by their raw string in a `Set`
|
||||
/// (O(1) per value) instead of an O(n) pairwise `valueMatches` scan; the
|
||||
/// value-space and whitespace-collapsing types return false and keep the
|
||||
/// exact pairwise comparison.
|
||||
var comparesRawLexically: Bool {
|
||||
guard case .atomic = variety, base.primitive.comparesLexically else { return false }
|
||||
if case .preserve = facets.whiteSpace ?? base.whiteSpace { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Whether `instance` and `literal` denote the same value in this type's
|
||||
/// value space, the comparison a RELAX NG `<value>` performs. Ordered
|
||||
/// types (numeric, date/time) compare in value space, so `1` equals `01`
|
||||
|
||||
39
Sources/Schema/StepSiblingIndex.swift
Normal file
39
Sources/Schema/StepSiblingIndex.swift
Normal file
@ -0,0 +1,39 @@
|
||||
/// Caches, per parent element and child local name, the one-based position of
|
||||
/// each same-name element child among its like-named siblings, used to locate an
|
||||
/// identity-constraint error precisely (`item[3]` rather than `item`).
|
||||
///
|
||||
/// Without it, ``IdentityValidator/steps(from:to:)`` rescans a parent's whole
|
||||
/// child list for every target it locates, so validating a wide list of like-
|
||||
/// named elements is O(n) per target and quadratic overall. The first lookup
|
||||
/// under a given parent and name builds the position table once; later lookups
|
||||
/// are dictionary reads. A position is recorded only when more than one element
|
||||
/// shares the name, matching the path grammar, which omits the predicate for a
|
||||
/// unique child.
|
||||
final class StepSiblingIndex {
|
||||
typealias Tree = PureXML.Model.TreeNode
|
||||
|
||||
/// parent identity -> child local name -> child identity -> one-based
|
||||
/// position among same-name element siblings (present only when several
|
||||
/// siblings share the name).
|
||||
private var positions: [ObjectIdentifier: [String: [ObjectIdentifier: Int]]] = [:]
|
||||
|
||||
/// The one-based position of `node` among `parent`'s element children named
|
||||
/// `name`, or nil when it is the only such child (no predicate needed).
|
||||
func position(of node: Tree, under parent: Tree, name: String) -> Int? {
|
||||
let parentIdentity = ObjectIdentifier(parent)
|
||||
if let table = positions[parentIdentity]?[name] {
|
||||
return table[ObjectIdentifier(node)]
|
||||
}
|
||||
var table: [ObjectIdentifier: Int] = [:]
|
||||
var ordinal = 0
|
||||
for child in parent.children where child.kind == .element && (child.name?.description ?? "") == name {
|
||||
ordinal += 1
|
||||
table[ObjectIdentifier(child)] = ordinal
|
||||
}
|
||||
// The grammar predicates a step only when the name is ambiguous; a single
|
||||
// same-name child keeps the bare `name`, so drop a lone entry.
|
||||
if ordinal < 2 { table = [:] }
|
||||
positions[parentIdentity, default: [:]][name] = table
|
||||
return table[ObjectIdentifier(node)]
|
||||
}
|
||||
}
|
||||
82
Tests/SchemaIdentityScaleDuplicateTests.swift
Normal file
82
Tests/SchemaIdentityScaleDuplicateTests.swift
Normal file
@ -0,0 +1,82 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// `unique`/`key`/`keyref` detect duplicates through a raw-string `Set` fast path
|
||||
/// for whitespace-preserving lexical fields (most ids) and an exact value-space
|
||||
/// scan for the rest, so a wide list validates in linear time without changing
|
||||
/// which documents pass. These pin that the fast path and the fallback agree:
|
||||
/// raw-string duplicates are caught, value-space and whitespace-collapsing
|
||||
/// duplicates that the fast path deliberately skips are still caught by the
|
||||
/// fallback, and a colliding target keeps its positional error location.
|
||||
@Suite("Identity-constraint duplicate detection across the fast path and fallback")
|
||||
struct SchemaIdentityScaleDuplicateTests {
|
||||
private func uniqueSchema(field: String, type: String) throws -> PureXML.Schema.Document {
|
||||
let content = field == "@v"
|
||||
? "<xsd:attribute name=\"v\" type=\"\(type)\"/>"
|
||||
: "<xsd:simpleContent><xsd:extension base=\"\(type)\"/></xsd:simpleContent>"
|
||||
return try PureXML.Schema.Document("""
|
||||
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<xsd:element name="catalog">
|
||||
<xsd:complexType><xsd:sequence>
|
||||
<xsd:element name="item" maxOccurs="unbounded">
|
||||
<xsd:complexType>\(content)</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:sequence></xsd:complexType>
|
||||
<xsd:unique name="u"><xsd:selector xpath="item"/><xsd:field xpath="\(field)"/></xsd:unique>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("a wide list of distinct string ids passes; one repeat is caught")
|
||||
func test_rawFastPath() throws {
|
||||
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
|
||||
let distinct = "<catalog>" + (0 ..< 500).map { "<item v=\"i\($0)\"/>" }.joined() + "</catalog>"
|
||||
#expect(try schema.validate(distinct).isEmpty)
|
||||
let repeated = "<catalog>" + (0 ..< 499).map { "<item v=\"i\($0)\"/>" }.joined() + "<item v=\"i0\"/></catalog>"
|
||||
#expect(try !schema.validate(repeated).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a whitespace-collapsing duplicate the raw path skips is caught by the fallback")
|
||||
func test_collapseFallback() throws {
|
||||
// xsd:token collapses internal runs, so "a b" and "a b" are one value.
|
||||
// Selected by xsi:type so the field carries the token type, it is not
|
||||
// whitespace-preserving and so bypasses the raw-string Set, colliding only
|
||||
// through the exact value-space scan.
|
||||
let schema = try PureXML.Schema.Document("""
|
||||
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<xsd:element name="catalog">
|
||||
<xsd:complexType><xsd:sequence>
|
||||
<xsd:element name="item" type="xsd:anyType" maxOccurs="unbounded"/>
|
||||
</xsd:sequence></xsd:complexType>
|
||||
<xsd:unique name="u"><xsd:selector xpath="item"/><xsd:field xpath="."/></xsd:unique>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
""")
|
||||
func doc(_ first: String, _ second: String) -> String {
|
||||
"<catalog xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">"
|
||||
+ "<item xsi:type=\"xsd:token\">\(first)</item><item xsi:type=\"xsd:token\">\(second)</item></catalog>"
|
||||
}
|
||||
#expect(try !schema.validate(doc("a b", "a b")).isEmpty)
|
||||
#expect(try schema.validate(doc("a b", "c d")).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a colon-bearing string duplicate is still caught")
|
||||
func test_colonValue() throws {
|
||||
// A value with a colon is conservatively routed to the value-space scan
|
||||
// (it could be a QName), so duplicates must still be detected.
|
||||
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
|
||||
#expect(try !schema.validate("<catalog><item v=\"a:b\"/><item v=\"a:b\"/></catalog>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("a duplicate among many same-name siblings keeps its positional location")
|
||||
func test_errorLocationPredicate() throws {
|
||||
let schema = try uniqueSchema(field: "@v", type: "xsd:string")
|
||||
let doc = "<catalog>" + (0 ..< 5).map { "<item v=\"i\($0)\"/>" }.joined() + "<item v=\"i0\"/></catalog>"
|
||||
let errors = try schema.validate(doc)
|
||||
#expect(errors.count == 1)
|
||||
// The sixth item collides; its located path names item[6], proving the
|
||||
// cached sibling index numbers the colliding target correctly.
|
||||
#expect(errors.first.map { "\($0)".contains("item") && "\($0)".contains("6") } == true)
|
||||
}
|
||||
}
|
||||
@ -117,3 +117,23 @@ wide parent. Tens of thousands of single-node extractions each rescanned the
|
||||
makes it linear: ~2.3 s -> ~0.65 s (~3.5x), output byte-identical (Xalan
|
||||
gold-output verified). The fix is the same `count <= 1` early-out that
|
||||
`sortedByDocumentOrder()` already had.
|
||||
|
||||
## XSD identity constraints (quadratic fix, found by per-operation scale-testing)
|
||||
|
||||
Validating a wide list against an `xs:unique`/`xs:key`/`xs:keyref` was
|
||||
quadratic for two independent reasons, both invisible on the default corpus
|
||||
(which has no identity constraints) and surfaced only by scale-testing the
|
||||
operation on its own at 2x/4x/8x sizes. First, the duplicate check compared
|
||||
each new value tuple against every tuple already collected (and a `keyref`
|
||||
scanned every referenced key), so n targets cost O(n^2). Field equality is
|
||||
value-space aware (`3.0` equals `3`, `true` equals `1`, a QName equals an
|
||||
equivalent prefix), so the tuples cannot be hashed directly. The fix keys only
|
||||
the tuples that provably compare as raw strings (whitespace-preserving lexical
|
||||
types with no colon, which is most ids) in a `Set`, and keeps the exact
|
||||
pairwise comparison for value-space, whitespace-collapsing, and QName-bearing
|
||||
fields, so which documents pass is unchanged. Second, the error-location helper
|
||||
rescanned the parent's whole child list to position each target (`item[3]`),
|
||||
another O(n) per target; it now uses a per-parent, per-name index built once.
|
||||
Together: a 16000-item `xs:unique` document drops from ~4.0 s to ~0.12 s
|
||||
(~34x), and the cost is now linear. The full local suite and the W3C XSTS
|
||||
datatype/identity conformance corpus are unchanged.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user