Extract PathKey.steps(forChildNames:) as the single path construction behind the validator walk, the XSD content validator, and the identity validator, replacing three identical implementations, so located errors can never disagree on a path. Add two differentials locking the duplicated engines together: equivalent DTD and XSD content models (sequence, choice, *, +, ?, nested choice) must agree on every child sequence across the positional matcher and the Thompson NFA; and the pull (EventReader) and push (PushScanner) tokenizers must yield the same event stream for the same document, even with the push side fed one character at a time (adjacent text events coalesced, since chunk boundaries legitimately split text). Both pairs agree on everything today; the differentials guard them. Closes #115
215 lines
10 KiB
Swift
215 lines
10 KiB
Swift
/// 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
|
|
/// name carries it: the selector XPath chooses target nodes relative to that
|
|
/// element, and the field XPaths form each target's value tuple. `key` and
|
|
/// `unique` require distinct tuples; `keyref` requires each tuple to match a
|
|
/// key or unique of the named constraint visible in an enclosing scope.
|
|
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,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [PureXML.Validation.ValidationError] {
|
|
guard !constraints.isEmpty else { return [] }
|
|
// A selector or field XPath that does not compile is a schema-author
|
|
// error, reported once up front rather than silently disabling the
|
|
// constraint (the broken query still evaluates as no-match below).
|
|
var issues: [PureXML.Validation.ValidationError] = compileErrors(at: path)
|
|
walk(root, at: path, scopes: [], into: &issues)
|
|
return issues
|
|
}
|
|
|
|
/// One located error per constraint XPath that fails to compile, in a
|
|
/// deterministic order. The failed compilations are cached, so the walk
|
|
/// afterwards treats those constraints as matching nothing.
|
|
private func compileErrors(at path: [PureXML.Validation.PathKey]) -> [PureXML.Validation.ValidationError] {
|
|
var errors: [PureXML.Validation.ValidationError] = []
|
|
for key in constraints.keys.sorted() {
|
|
for constraint in constraints[key] ?? [] {
|
|
if cache.query(constraint.selector) == nil {
|
|
errors.append(.init(reason: "identity constraint '\(constraint.name)': invalid selector XPath '\(constraint.selector)'", at: path))
|
|
}
|
|
for field in constraint.fields where cache.query(field) == nil {
|
|
errors.append(.init(reason: "identity constraint '\(constraint.name)': invalid field XPath '\(field)'", at: path))
|
|
}
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
private func walk(
|
|
_ node: PureXML.Model.TreeNode,
|
|
at path: [PureXML.Validation.PathKey],
|
|
scopes: [[String: [[String?]]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
var frame: [String: [[String?]]] = [:]
|
|
let declared = node.name.flatMap { constraints[$0.localName] } ?? []
|
|
// Collect key and unique tuples first so a keyref on the same element
|
|
// can resolve against them.
|
|
for constraint in declared {
|
|
collect(constraint, at: node, path: path, frame: &frame, into: &issues)
|
|
}
|
|
let visible = scopes + [frame]
|
|
for constraint in declared {
|
|
if case .keyref = constraint.kind {
|
|
checkKeyref(constraint, at: node, path: path, scopes: visible, into: &issues)
|
|
}
|
|
}
|
|
let elementChildren = node.children.filter { $0.kind == .element }
|
|
for (child, step) in zip(elementChildren, childSteps(elementChildren)) {
|
|
walk(child, at: path + [step], scopes: visible, into: &issues)
|
|
}
|
|
}
|
|
|
|
/// The coding-path step for each element child: its name, with a sibling
|
|
/// index only when more than one child shares that name. Mirrors the
|
|
/// complex-type validator's path construction so identity-constraint
|
|
/// errors and content errors locate consistently.
|
|
private func childSteps(_ children: [PureXML.Model.TreeNode]) -> [PureXML.Validation.PathKey] {
|
|
PureXML.Validation.PathKey.steps(forChildNames: children.map { $0.name?.description ?? "" })
|
|
}
|
|
|
|
/// 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.
|
|
/// Returns an empty path if `target` is not a descendant of `container`.
|
|
private func steps(from container: PureXML.Model.TreeNode, to target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] {
|
|
var chain: [PureXML.Model.TreeNode] = []
|
|
var current = target
|
|
while current !== container {
|
|
guard let parent = current.parent else { return [] }
|
|
chain.append(current)
|
|
current = parent
|
|
}
|
|
var steps: [PureXML.Validation.PathKey] = []
|
|
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))
|
|
} else {
|
|
steps.append(.element(name))
|
|
}
|
|
parent = node
|
|
}
|
|
return steps
|
|
}
|
|
|
|
/// The coding-path step from a `target` down to the single field a
|
|
/// constraint names, so an error locates the offending value (`@id`, a
|
|
/// child element) rather than just the target. Empty for a multi-field
|
|
/// constraint or a complex field path, in which case the error stays on the
|
|
/// target.
|
|
private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] {
|
|
guard fields.count == 1 else { return [] }
|
|
let field = fields[0]
|
|
if field.hasPrefix("@"), !field.dropFirst().contains(where: { $0 == "/" || $0 == "[" }) {
|
|
return [.attribute(String(field.dropFirst()))]
|
|
}
|
|
let isSimpleName = !field.contains(where: { "/@[(".contains($0) })
|
|
if isSimpleName, let child = select(field, at: target).first, child.kind == .element {
|
|
return steps(from: target, to: child)
|
|
}
|
|
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,
|
|
path: [PureXML.Validation.PathKey],
|
|
frame: inout [String: [[String?]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
guard isNonRef(constraint) else { return }
|
|
var tuples: [[String?]] = []
|
|
var seen: [[String?]] = []
|
|
for target in select(constraint.selector, at: node) {
|
|
let tuple = fieldTuple(constraint.fields, at: target)
|
|
let targetPath = path + steps(from: node, to: target)
|
|
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
|
|
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
continue
|
|
}
|
|
if tuple.contains(where: { $0 == nil }) { continue }
|
|
if seen.contains(where: { $0 == tuple }) {
|
|
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
} else {
|
|
seen.append(tuple)
|
|
}
|
|
tuples.append(tuple)
|
|
}
|
|
frame[constraint.name] = tuples
|
|
}
|
|
|
|
private func checkKeyref(
|
|
_ constraint: IdentityConstraint,
|
|
at node: PureXML.Model.TreeNode,
|
|
path: [PureXML.Validation.PathKey],
|
|
scopes: [[String: [[String?]]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
guard case let .keyref(refer) = constraint.kind else { return }
|
|
let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
|
|
for target in select(constraint.selector, at: node) {
|
|
let tuple = fieldTuple(constraint.fields, at: target)
|
|
if tuple.contains(where: { $0 == nil }) { continue }
|
|
if !keyTuples.contains(where: { $0 == tuple }) {
|
|
let targetPath = path + steps(from: node, to: target)
|
|
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: XPath helpers
|
|
|
|
private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
|
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 = cache.query(field), let value = try? query.value(at: node) else {
|
|
return nil
|
|
}
|
|
if let nodes = value.nodes, nodes.isEmpty { return nil }
|
|
return value.string
|
|
}
|
|
}
|
|
|
|
private func label(_ constraint: IdentityConstraint) -> String {
|
|
constraint.kind == .key ? "key" : "unique"
|
|
}
|
|
}
|
|
}
|