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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:43:20 +02:00
parent 4bcd7e2787
commit 4765af0624
9 changed files with 171 additions and 72 deletions

View File

@ -1,7 +1,13 @@
/// File-scope aliases for the XSD complex-type validator, kept out of the
/// namespace to avoid nesting a type two levels deep.
private typealias XSDFailure = PureXML.Validation.ValidationError
private typealias XSDPath = [PureXML.Validation.PathKey]
public extension PureXML.Schema {
/// Validates an element against a ``ComplexType``: its attribute uses, its
/// content model, and, recursively, each child element against the type
/// declared for its name.
/// declared for its name. Every violation is located by the element's coding
/// path, and the results are ``PureXML/Validation/ValidationError`` values.
struct ComplexValidator {
/// The named type table that `ElementType.typeReference` resolves against.
private let types: [String: ElementType]
@ -10,26 +16,28 @@ public extension PureXML.Schema {
self.types = types
}
/// Validates `element` against `type`, returning one issue per violation.
/// Validates `element` against `type` at `path`, one error per violation.
public func validate(
_ element: PureXML.Model.Element,
against type: ComplexType,
) -> [PureXML.Validation.Issue] {
var issues: [PureXML.Validation.Issue] = []
validateAttributes(element, type, into: &issues)
validateContent(element, type.content, into: &issues)
return issues
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateAttributes(element, type, at: path, into: &errors)
validateContent(element, type.content, at: path, into: &errors)
return errors
}
/// Validates `element` against any element type, resolving a
/// Validates `element` against any element type at `path`, resolving a
/// `typeReference` through the type table.
public func validate(
_ element: PureXML.Model.Element,
as type: ElementType,
) -> [PureXML.Validation.Issue] {
var issues: [PureXML.Validation.Issue] = []
validateChild(element, against: type, into: &issues)
return issues
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateChild(element, against: type, at: path, into: &errors)
return errors
}
// MARK: Attributes
@ -37,22 +45,23 @@ public extension PureXML.Schema {
private func validateAttributes(
_ element: PureXML.Model.Element,
_ type: ComplexType,
into issues: inout [PureXML.Validation.Issue],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
for use in type.attributes {
let match = present.first { $0.name.localName == use.name.localName }
if let match {
if let error = use.type.validate(match.value) {
issues.append(.init(severity: .error, message: "attribute '\(use.name.localName)': \(error)"))
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
} else if use.required {
issues.append(.init(severity: .error, message: "missing required attribute '\(use.name.localName)'"))
errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path))
}
}
guard !type.allowsOtherAttributes else { return }
for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) {
issues.append(.init(severity: .error, message: "undeclared attribute '\(attribute.name.localName)'"))
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
}
}
@ -61,33 +70,30 @@ public extension PureXML.Schema {
private func validateContent(
_ element: PureXML.Model.Element,
_ content: ContentType,
into issues: inout [PureXML.Validation.Issue],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let children = element.children.compactMap(\.element)
switch content {
case .empty:
if !children.isEmpty { issues.append(.init(severity: .error, message: "element must be empty")) }
rejectText(element, allowed: false, into: &issues)
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
rejectText(element, at: path, into: &errors)
case let .simpleContent(type):
if !children.isEmpty { issues.append(.init(severity: .error, message: "element must not have children")) }
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
if let error = type.validate(Self.textContent(element)) {
issues.append(.init(severity: .error, message: "content: \(error)"))
errors.append(XSDFailure(reason: "content: \(error)", at: path))
}
case let .elementOnly(particle):
rejectText(element, allowed: false, into: &issues)
validateParticle(particle, children: children, into: &issues)
rejectText(element, at: path, into: &errors)
validateParticle(particle, children: children, at: path, into: &errors)
case let .mixed(particle):
validateParticle(particle, children: children, into: &issues)
validateParticle(particle, children: children, at: path, into: &errors)
}
}
private func rejectText(
_ element: PureXML.Model.Element,
allowed: Bool,
into issues: inout [PureXML.Validation.Issue],
) {
guard !allowed, !Self.textContent(element).isEmpty else { return }
issues.append(.init(severity: .error, message: "element must not contain text"))
private func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) {
guard !Self.textContent(element).isEmpty else { return }
errors.append(XSDFailure(reason: "element must not contain text", at: path))
}
// MARK: Particles
@ -95,7 +101,8 @@ public extension PureXML.Schema {
private func validateParticle(
_ particle: Particle,
children: [PureXML.Model.Element],
into issues: inout [PureXML.Validation.Issue],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let names = children.map(\.name)
let structureValid: Bool = if case let .group(group) = particle.term, group.compositor == .all {
@ -104,31 +111,34 @@ public extension PureXML.Schema {
ContentNFABuilder.build(particle).matchesWhole(names)
}
if !structureValid {
issues.append(.init(severity: .error, message: "content does not match the content model"))
errors.append(XSDFailure(reason: "content does not match the content model", at: path))
return
}
validateChildren(children, childTypes: Self.elementTypes(in: particle.term), into: &issues)
validateChildren(children, childTypes: Self.elementTypes(in: particle.term), at: path, into: &errors)
}
private func validateChildren(
_ children: [PureXML.Model.Element],
childTypes: [String: ElementType],
into issues: inout [PureXML.Validation.Issue],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
for child in children {
let steps = Self.childSteps(children)
for (child, step) in zip(children, steps) {
guard let declared = childTypes[Self.key(child.name)] else { continue }
validateChild(child, against: declared, into: &issues)
validateChild(child, against: declared, at: path + [step], into: &errors)
}
}
private func validateChild(
_ child: PureXML.Model.Element,
against declared: ElementType,
into issues: inout [PureXML.Validation.Issue],
at path: XSDPath,
into errors: inout [XSDFailure],
) {
// An instance `xsi:type` overrides the declared type, provided the
// named type exists in the schema. Resolve to the type itself rather
// than a reference so re-entry does not re-read the same xsi:type.
// An instance `xsi:type` overrides the declared type, provided the named
// type exists in the schema. Resolve to the type itself rather than a
// reference so re-entry does not re-read the same xsi:type.
var declared = declared
if let overriding = Self.xsiTypeName(child), let resolved = types[overriding] {
declared = resolved
@ -136,24 +146,40 @@ public extension PureXML.Schema {
switch declared {
case let .simple(simple):
if !child.children.compactMap(\.element).isEmpty {
issues.append(.init(severity: .error, message: "'\(child.name.localName)' must not have children"))
errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path))
}
if let error = simple.validate(Self.textContent(child)) {
issues.append(.init(severity: .error, message: "'\(child.name.localName)': \(error)"))
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
}
case let .complex(complex):
issues.append(contentsOf: validate(child, against: complex))
errors.append(contentsOf: validate(child, against: complex, at: path))
case let .typeReference(name):
guard let resolved = types[name] else {
issues.append(.init(severity: .error, message: "unknown type '\(name)'"))
errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path))
return
}
validateChild(child, against: resolved, into: &issues)
validateChild(child, against: resolved, at: path, into: &errors)
}
}
// MARK: Helpers
/// The coding-path step for each child: its name, with a sibling index only
/// when more than one child shares that name.
private static func childSteps(_ children: [PureXML.Model.Element]) -> [PureXML.Validation.PathKey] {
var totals: [String: Int] = [:]
for child in children {
totals[child.name.description, default: 0] += 1
}
var seen: [String: Int] = [:]
return children.map { child in
let name = child.name.description
let index = (seen[name] ?? 0) + 1
seen[name] = index
return (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name)
}
}
/// Validates an `all` group order-independently: every child matches a
/// member within its occurrence bounds, and each member meets its minimum.
private static func matchesAll(_ group: Group, names: [PureXML.Model.QualifiedName]) -> Bool {
@ -221,10 +247,9 @@ public extension PureXML.Schema {
attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi"
}
/// The local name of an element's `xsi:type` attribute value, or nil when
/// it carries none. Recognizes the attribute by the XML Schema instance
/// namespace or, failing namespace resolution, the conventional `xsi`
/// prefix.
/// The local name of an element's `xsi:type` attribute value, or nil when it
/// carries none. Recognizes the attribute by the XML Schema instance
/// namespace or, failing namespace resolution, the conventional `xsi` prefix.
private static func xsiTypeName(_ element: PureXML.Model.Element) -> String? {
let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance"
let match = element.attributes.first { attribute in

View File

@ -9,9 +9,9 @@ extension PureXML.Schema {
/// The constraints declared for each element local name.
let constraints: [String: [IdentityConstraint]]
func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.Issue] {
func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.ValidationError] {
guard !constraints.isEmpty else { return [] }
var issues: [PureXML.Validation.Issue] = []
var issues: [PureXML.Validation.ValidationError] = []
walk(root, scopes: [], into: &issues)
return issues
}
@ -19,7 +19,7 @@ extension PureXML.Schema {
private func walk(
_ node: PureXML.Model.TreeNode,
scopes: [[String: [[String?]]]],
into issues: inout [PureXML.Validation.Issue],
into issues: inout [PureXML.Validation.ValidationError],
) {
var frame: [String: [[String?]]] = [:]
let declared = node.name.flatMap { constraints[$0.localName] } ?? []
@ -48,7 +48,7 @@ extension PureXML.Schema {
_ constraint: IdentityConstraint,
at node: PureXML.Model.TreeNode,
frame: inout [String: [[String?]]],
into issues: inout [PureXML.Validation.Issue],
into issues: inout [PureXML.Validation.ValidationError],
) {
guard isNonRef(constraint) else { return }
var tuples: [[String?]] = []
@ -56,12 +56,12 @@ extension PureXML.Schema {
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(severity: .error, message: "key '\(constraint.name)': a field is missing"))
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(severity: .error, message: "\(label(constraint)) '\(constraint.name)': duplicate value"))
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: []))
} else {
seen.append(tuple)
}
@ -75,14 +75,14 @@ extension PureXML.Schema {
refer: String,
at node: PureXML.Model.TreeNode,
scopes: [[String: [[String?]]]],
into issues: inout [PureXML.Validation.Issue],
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(severity: .error, message: "keyref '\(constraint.name)': no matching key '\(refer)'"))
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: []))
}
}
}

View File

@ -29,21 +29,20 @@ public extension PureXML.Schema {
constraints = compiled.constraints
}
/// Validates an instance document against the schema, returning one issue
/// per violation. Reports an issue when the root element has no global
/// declaration.
public func validate(_ xml: String) throws -> [PureXML.Validation.Issue] {
/// Validates an instance document against the schema, returning one located
/// ``PureXML/Validation/ValidationError`` per violation. Reports an error
/// when the root element has no global declaration.
public func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = try PureXML.parse(xml),
let root = children.compactMap(\.element).first
else {
return [.init(severity: .error, message: "the document has no root element")]
return [.init(reason: "the document has no root element", at: [])]
}
guard let declaration = elements[root.name.localName] else {
return [.init(severity: .error, message: "no element declaration for '\(root.name.localName)'")]
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])]
}
var issues = ComplexValidator(types: types).validate(root, as: declaration)
issues += IdentityValidator(constraints: constraints).validate(PureXML.Model.TreeNode(.element(root)))
return issues
let context = PureXML.Validation.XSDContext(types: types, constraints: constraints, rootDeclaration: declaration)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
}
}

View File

@ -0,0 +1,59 @@
public extension PureXML.Validation {
/// The compiled-schema context the XSD validations read: the named-type table
/// a content model resolves against, the identity constraints, and the root
/// element's declared type.
struct XSDContext {
public let types: [String: PureXML.Schema.ElementType]
public let constraints: [String: [PureXML.Schema.IdentityConstraint]]
public let rootDeclaration: PureXML.Schema.ElementType?
public init(
types: [String: PureXML.Schema.ElementType],
constraints: [String: [PureXML.Schema.IdentityConstraint]],
rootDeclaration: PureXML.Schema.ElementType?,
) {
self.types = types
self.constraints = constraints
self.rootDeclaration = rootDeclaration
}
}
/// XSD validation expressed as composable ``Validation`` values over an
/// ``XSDContext``. The content-model check is inherently recursive (a child's
/// type depends on its position), so each rule delegates to the recursive
/// validators while contributing located ``ValidationError`` results to one
/// collection.
enum XSD {
/// The document element is valid against its declared XSD type.
static var contentValidity: Validation<PureXML.Model.Node, XSDContext> {
.init(
description: "The document element is valid against its XSD type",
check: { context in
guard case let .element(root) = context.subject, let declaration = context.document.rootDeclaration else {
return []
}
return PureXML.Schema.ComplexValidator(types: context.document.types)
.validate(root, as: declaration, at: [.element(root.name.description)])
},
when: { $0.codingPath.isEmpty },
)
}
/// The document's XSD identity constraints (`unique`, `key`, `keyref`) hold.
static var identityConstraints: Validation<PureXML.Model.Node, XSDContext> {
.init(
description: "XSD identity constraints hold",
check: { context in
PureXML.Schema.IdentityValidator(constraints: context.document.constraints)
.validate(PureXML.Model.TreeNode(context.subject))
},
when: { $0.codingPath.isEmpty },
)
}
/// A validator combining the content-model and identity-constraint rules.
static func validator() -> Validator<XSDContext> {
Validator<XSDContext>.blank.validating(contentValidity, identityConstraints)
}
}
}

View File

@ -18,7 +18,7 @@ struct XSDComplexTypeTests {
private enum TestFailure: Error { case noRoot }
private func validate(_ xml: String, _ type: Schema.ComplexType) throws -> [PureXML.Validation.Issue] {
private func validate(_ xml: String, _ type: Schema.ComplexType) throws -> [PureXML.Validation.ValidationError] {
try Schema.ComplexValidator().validate(parse(xml), against: type)
}

View File

@ -3,7 +3,7 @@ import Testing
@Suite("XSD list, union, groups, and references")
struct XSDCompositionTests {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd).validate(xml)
}

View File

@ -3,7 +3,7 @@ import Testing
@Suite("XSD identity constraints")
struct XSDIdentityTests {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd).validate(xml)
}

View File

@ -7,7 +7,7 @@ struct XSDImportTests {
_ xsd: String,
_ xml: String,
loader: @escaping (String) -> String? = { _ in nil },
) throws -> [PureXML.Validation.Issue] {
) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd, schemaLoader: loader).validate(xml)
}

View File

@ -3,10 +3,26 @@ import Testing
@Suite("XSD schema documents")
struct XSDSchemaTests {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd).validate(xml)
}
@Test("A nested type violation is located by coding path")
func test_violationCarriesPath() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order">
<xs:complexType>
<xs:sequence><xs:element name="qty" type="xs:integer"/></xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
"""
let found = try validate(xsd, "<order><qty>lots</qty></order>")
#expect(found.count == 1)
#expect(String(describing: found[0]).hasSuffix("at path: order/qty"))
}
@Test("A simple element with a built-in type")
func test_simpleElement() throws {
let xsd = """