PureXML/Sources/Schema/IdentityValidator.swift
Mihaela Mihaljevic 4765af0624 refactor(validation): migrate XSD validation onto the framework
Slice 4 of #92. The XSD complex-type and identity-constraint validators move
onto the validation framework, producing located results.

- ComplexValidator threads a coding path through its (inherently recursive)
  descent, so every content-model, attribute, and simple-type violation is now
  a ValidationError located by path ("... at path: order/qty"). Child steps
  carry a sibling index only when a name repeats.
- IdentityValidator returns ValidationError instead of Issue.
- A new XSD rules enum wraps both as Validation<Node, XSDContext> values gated to
  the document root, combined by Validator<XSDContext>. XSDContext carries the
  type table, the identity constraints, and the root declaration.
- Schema.Document.validate builds the context and runs the validator, returning
  [ValidationError].

XSD content-model validation is recursive by nature (a child's type depends on
its position), so the rules delegate to the recursion while contributing to one
error collection. Schematron is the last validator on Issue and migrates next.
2026-06-08 12:43:20 +02:00

112 lines
4.9 KiB
Swift

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]]
func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.ValidationError] {
guard !constraints.isEmpty else { return [] }
var issues: [PureXML.Validation.ValidationError] = []
walk(root, scopes: [], into: &issues)
return issues
}
private func walk(
_ node: PureXML.Model.TreeNode,
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, frame: &frame, into: &issues)
}
let visible = scopes + [frame]
for constraint in declared {
if case let .keyref(refer) = constraint.kind {
checkKeyref(constraint, refer: refer, at: node, scopes: visible, into: &issues)
}
}
for child in node.children where child.kind == .element {
walk(child, scopes: visible, into: &issues)
}
}
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,
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)
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: []))
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: []))
} else {
seen.append(tuple)
}
tuples.append(tuple)
}
frame[constraint.name] = tuples
}
private func checkKeyref(
_ constraint: IdentityConstraint,
refer: String,
at node: PureXML.Model.TreeNode,
scopes: [[String: [[String?]]]],
into issues: inout [PureXML.Validation.ValidationError],
) {
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 }) {
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: []))
}
}
}
// 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 [] }
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 {
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"
}
}
}