Merge XSD validation framework slice 4 (#92): located XSD results
This commit is contained in:
commit
e925ba6bea
@ -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
|
||||
|
||||
@ -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: []))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
Sources/Schema/XSDValidations.swift
Normal file
59
Sources/Schema/XSDValidations.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 = """
|
||||
|
||||
Loading…
Reference in New Issue
Block a user