The two monolithic XSD content/identity rules only satisfied the OpenAPIKit validation idiom at the wrong granularity: no per-constraint removability, no isolable failing/near-miss tests. Split them into 32 named rules, each finding tagged at its origin and cached per XSDContext so the walk still runs once per document (the #101 decision against duplicating XSD's recursive type resolution still holds). Wired the streaming path's ID/IDREF and identity checks through the same framework instead of calling the validators imperatively, and added the unwrapAndLookup combinator the rule already named as required. Also includes the mandated repo-wide swiftformat/swiftlint pass.
348 lines
19 KiB
Swift
348 lines
19 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]]
|
|
/// Simple types for identity-constraint fields keyed by
|
|
/// ``XSDParser/identityFieldKey(selector:field:)``.
|
|
let fieldTypes: [String: SimpleType]
|
|
/// `default`/`fixed` value constraints on identity-constraint field
|
|
/// targets, keyed the same way as ``fieldTypes``. When a field selects an
|
|
/// absent attribute or an empty element whose declaration carries one of
|
|
/// these, the field's identity value is that value, not nothing.
|
|
let fieldConstraints: [String: PureXML.Schema.ValueConstraint]
|
|
/// Keys (``XSDParser/identityFieldKey(constraint:field:)``) of fields whose
|
|
/// declared target is not a simple type, computed at compile time, so the
|
|
/// field violates cvc-identity-constraint.3 (XSTS idZ010).
|
|
let nonSimpleFields: Set<String>
|
|
/// Global element and type declarations for runtime field typing.
|
|
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]],
|
|
fieldTypes: [String: SimpleType] = [:],
|
|
fieldConstraints: [String: PureXML.Schema.ValueConstraint] = [:],
|
|
nonSimpleFields: Set<String> = [],
|
|
types: [String: ElementType] = [:],
|
|
) {
|
|
self.constraints = constraints
|
|
self.fieldTypes = fieldTypes
|
|
self.fieldConstraints = fieldConstraints
|
|
self.nonSimpleFields = nonSimpleFields
|
|
self.types = types
|
|
}
|
|
|
|
func validate(
|
|
_ root: PureXML.Model.TreeNode,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [PureXML.Validation.ValidationError] {
|
|
validateTagged(root, at: path).map(\.error)
|
|
}
|
|
|
|
/// The tagged form of ``validate(_:at:)``: the same computation, with each
|
|
/// finding labeled by the decomposed identity rule it belongs to. The
|
|
/// public, untagged form is a thin `.map(\.error)` over this so behavior and
|
|
/// output stay identical for every existing caller.
|
|
func validateTagged(
|
|
_ root: PureXML.Model.TreeNode,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [XSDFinding] {
|
|
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: [XSDFinding] = 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]) -> [XSDFinding] {
|
|
var errors: [XSDFinding] = []
|
|
for key in constraints.keys.sorted() {
|
|
for constraint in constraints[key] ?? [] {
|
|
if cache.query(constraint.selector) == nil {
|
|
errors.append(XSDFinding(
|
|
.identityConstraintXPathsValid,
|
|
reason: "identity constraint '\(constraint.name)': invalid selector XPath '\(constraint.selector)'",
|
|
at: path,
|
|
))
|
|
}
|
|
for field in constraint.fields where cache.query(field) == nil {
|
|
errors.append(XSDFinding(.identityConstraintXPathsValid, 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: [[FieldValue?]]]],
|
|
into issues: inout [XSDFinding],
|
|
) {
|
|
var frame: [String: [[FieldValue?]]] = [:]
|
|
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 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, namespaces: [String: String]) -> [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, namespaces: namespaces).first, child.kind == .element {
|
|
return steps(from: target, to: child)
|
|
}
|
|
return []
|
|
}
|
|
|
|
private func collect(
|
|
_ constraint: IdentityConstraint,
|
|
at node: PureXML.Model.TreeNode,
|
|
path: [PureXML.Validation.PathKey],
|
|
frame: inout [String: [[FieldValue?]]],
|
|
into issues: inout [XSDFinding],
|
|
) {
|
|
guard isNonRef(constraint) else { return }
|
|
var tuples: [[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) {
|
|
issues.append(error)
|
|
continue
|
|
}
|
|
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
|
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
|
|
issues.append(XSDFinding(
|
|
.identityKeyFieldPresent,
|
|
reason: "key '\(constraint.name)': a field is missing",
|
|
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
|
))
|
|
continue
|
|
}
|
|
if tuple.contains(where: { $0 == nil }) {
|
|
continue
|
|
}
|
|
if seen.contains(tuple) {
|
|
issues.append(XSDFinding(
|
|
.identityValueUnique,
|
|
reason: "\(label(constraint)) '\(constraint.name)': duplicate value",
|
|
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
|
))
|
|
} else {
|
|
seen.insert(tuple)
|
|
}
|
|
tuples.append(tuple)
|
|
}
|
|
frame[constraint.name] = tuples
|
|
}
|
|
|
|
private func checkKeyref(
|
|
_ constraint: IdentityConstraint,
|
|
at node: PureXML.Model.TreeNode,
|
|
path: [PureXML.Validation.PathKey],
|
|
scopes: [[String: [[FieldValue?]]]],
|
|
into issues: inout [XSDFinding],
|
|
) {
|
|
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) {
|
|
issues.append(error)
|
|
continue
|
|
}
|
|
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
|
if tuple.contains(where: { $0 == nil }) {
|
|
continue
|
|
}
|
|
if !keys.contains(tuple) {
|
|
issues.append(XSDFinding(
|
|
.identityKeyrefResolves,
|
|
reason: "keyref '\(constraint.name)': no matching key '\(refer)'",
|
|
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The error when a field of `constraint` selects more than one node at
|
|
/// `target`. A field must evaluate to a node-set with at most one member
|
|
/// (Identity-constraint Definition; cvc-identity-constraint.3); selecting
|
|
/// several is a violation, not a multi-valued key.
|
|
private func fieldError(
|
|
_ constraint: IdentityConstraint,
|
|
at target: PureXML.Model.TreeNode,
|
|
path targetPath: [PureXML.Validation.PathKey],
|
|
) -> XSDFinding? {
|
|
let step = fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings)
|
|
for field in constraint.fields {
|
|
let selected = select(field, at: target, namespaces: constraint.namespaceBindings)
|
|
// cvc-identity-constraint.3: each field must evaluate to at most one node.
|
|
if selected.count > 1 {
|
|
return XSDFinding(
|
|
.identityFieldCardinality,
|
|
reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must select at most one node",
|
|
at: targetPath + step,
|
|
)
|
|
}
|
|
// cvc-identity-constraint.3 / c-fields-xpaths: a field must identify a node
|
|
// with a simple type (or simple content). An element with element children
|
|
// has complex content (checked structurally), and an element whose DECLARED
|
|
// type is a complex type with non-simple content (empty or element-only, so
|
|
// an attributes-only element with no children, XSTS idG006) is equally
|
|
// invalid (checked through the schema type model). A simple-typed or
|
|
// simple-content element, a text-only node, and an attribute are left alone.
|
|
let element = selected.first.flatMap { $0.kind == .element ? $0 : nil }
|
|
let hasElementChildren = element?.children.contains { $0.kind == .element } ?? false
|
|
// A field whose DECLARED target is non-simple (the ur-type anyType or
|
|
// an inline complex type without simple content, resolved at compile
|
|
// time, XSTS idZ010) is invalid unless the instance gives the node a
|
|
// simple type through xsi:type: cvc-identity-constraint.3 reads the
|
|
// node's governing type, so a text-only anyType element validates only
|
|
// when xsi:type names a simple type. With an xsi:type present the
|
|
// runtime value comparison already governs, so the field is left.
|
|
let declaredNonSimple = element.map { node in
|
|
nonSimpleFields.contains(PureXML.Schema.XSDParser.identityFieldKey(constraint: constraint, field: field))
|
|
&& Self.xsiTypeLocalName(of: node) == nil
|
|
} ?? false
|
|
if element != nil, hasElementChildren || declaredNonSimple || fieldTargetIsComplexNonSimple(field, at: target) {
|
|
return XSDFinding(
|
|
.identityFieldMustBeSimple,
|
|
reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must identify a node with simple content",
|
|
at: targetPath + step,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: XPath helpers
|
|
|
|
private func select(_ xpath: String, at node: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.Model.TreeNode] {
|
|
guard let query = cache.query(xpath) else { return [] }
|
|
return query.nodes(over: node, namespaces: namespaces)
|
|
}
|
|
|
|
private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode, constraint: IdentityConstraint) -> [FieldValue?] {
|
|
// The field XPath's prefixes resolve against the schema (where the
|
|
// constraint is declared); a QName field VALUE resolves against the
|
|
// instance, so the FieldValue keeps the instance bindings.
|
|
let instanceNamespaces = namespaceBindings(at: node)
|
|
return fields.map { field in
|
|
guard let query = cache.query(field),
|
|
let value = try? query.value(at: node, namespaces: constraint.namespaceBindings)
|
|
else {
|
|
return nil
|
|
}
|
|
let type = resolveFieldType(field, constraint: constraint, at: node)
|
|
// An absent attribute (empty node-set) or an empty element whose
|
|
// declaration carries a `default`/`fixed` takes that value as its
|
|
// identity component. A present attribute (even value "") and a
|
|
// non-empty element are instance-supplied and never defaulted.
|
|
if let defaulted = defaultedValue(field, value: value, type: type, constraint: constraint, at: node) {
|
|
return defaulted
|
|
}
|
|
if let nodes = value.nodes, nodes.isEmpty {
|
|
return nil
|
|
}
|
|
return FieldValue(string: value.string, type: type ?? Self.effectiveType(of: value), namespaceBindings: instanceNamespaces)
|
|
}
|
|
}
|
|
|
|
/// Prefix bindings in scope at `node`, gathered from ancestor elements.
|
|
func namespaceBindings(at node: PureXML.Model.TreeNode) -> [String: String] {
|
|
var chain: [PureXML.Model.TreeNode] = []
|
|
var current: PureXML.Model.TreeNode? = node
|
|
while let element = current, element.kind == .element {
|
|
chain.append(element)
|
|
current = element.parent
|
|
}
|
|
var bindings: [String: String] = [:]
|
|
for element in chain.reversed() {
|
|
for attribute in element.attributes {
|
|
if attribute.name.prefix == "xmlns" {
|
|
bindings[attribute.name.localName] = attribute.value
|
|
} else if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
|
|
bindings[""] = attribute.value
|
|
}
|
|
}
|
|
}
|
|
return bindings
|
|
}
|
|
|
|
/// The simple type a field value compares in: the built-in named by the
|
|
/// selected node's `xsi:type`, when present. Identity-constraint values
|
|
/// compare in their value space, so two `xsi:type="xsd:decimal"` fields
|
|
/// `3.0` and `3` are the same value and violate a `unique`/`key`. With no
|
|
/// `xsi:type` the comparison falls back to the lexical form (the type
|
|
/// declared in the schema is not threaded into identity validation).
|
|
private func resolveFieldType(_ field: String, constraint: IdentityConstraint, at target: PureXML.Model.TreeNode) -> SimpleType? {
|
|
if field == ".", let local = target.name?.localName {
|
|
if let type = fieldTypes[PureXML.Schema.XSDParser.identityFieldKey(constraint: constraint, field: field, targetLocal: local)] {
|
|
return type
|
|
}
|
|
guard let name = target.name else { return nil }
|
|
if let declaration = types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] {
|
|
return simpleType(of: declaration)
|
|
}
|
|
if let declaration = types[PureXML.Schema.XSDParser.elementKey(name.localName)] {
|
|
return simpleType(of: declaration)
|
|
}
|
|
}
|
|
return fieldTypes[PureXML.Schema.XSDParser.identityFieldKey(constraint: constraint, field: field)]
|
|
}
|
|
}
|
|
}
|