From 4765af0624b2babebd6338cea11fa7dfdf09b0bd Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 12:43:20 +0200 Subject: [PATCH] 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 values gated to the document root, combined by Validator. 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. --- Sources/Schema/ComplexValidator.swift | 125 +++++++++++++++---------- Sources/Schema/IdentityValidator.swift | 16 ++-- Sources/Schema/SchemaDocument.swift | 17 ++-- Sources/Schema/XSDValidations.swift | 59 ++++++++++++ Tests/XSDComplexTypeTests.swift | 2 +- Tests/XSDCompositionTests.swift | 2 +- Tests/XSDIdentityTests.swift | 2 +- Tests/XSDImportTests.swift | 2 +- Tests/XSDSchemaTests.swift | 18 +++- 9 files changed, 171 insertions(+), 72 deletions(-) create mode 100644 Sources/Schema/XSDValidations.swift diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 3cc6fa1..992a419 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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 diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 5554aa4..65169b7 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -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: [])) } } } diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index df2c4bb..132e32d 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -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) } } } diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift new file mode 100644 index 0000000..27ba99c --- /dev/null +++ b/Sources/Schema/XSDValidations.swift @@ -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 { + .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 { + .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 { + Validator.blank.validating(contentValidity, identityConstraints) + } + } +} diff --git a/Tests/XSDComplexTypeTests.swift b/Tests/XSDComplexTypeTests.swift index 74590af..6c52d27 100644 --- a/Tests/XSDComplexTypeTests.swift +++ b/Tests/XSDComplexTypeTests.swift @@ -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) } diff --git a/Tests/XSDCompositionTests.swift b/Tests/XSDCompositionTests.swift index cd8c747..f943634 100644 --- a/Tests/XSDCompositionTests.swift +++ b/Tests/XSDCompositionTests.swift @@ -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) } diff --git a/Tests/XSDIdentityTests.swift b/Tests/XSDIdentityTests.swift index e84df74..90b4c93 100644 --- a/Tests/XSDIdentityTests.swift +++ b/Tests/XSDIdentityTests.swift @@ -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) } diff --git a/Tests/XSDImportTests.swift b/Tests/XSDImportTests.swift index 855151a..b85856e 100644 --- a/Tests/XSDImportTests.swift +++ b/Tests/XSDImportTests.swift @@ -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) } diff --git a/Tests/XSDSchemaTests.swift b/Tests/XSDSchemaTests.swift index 54df2ec..77a21d2 100644 --- a/Tests/XSDSchemaTests.swift +++ b/Tests/XSDSchemaTests.swift @@ -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 = """ + + + + + + + + """ + let found = try validate(xsd, "lots") + #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 = """