Merge XSD validation framework slice 4 (#92): located XSD results

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:43:21 +02:00
commit e925ba6bea
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 = """