From 50a6afb3fcd857d76818f9f0e81627ca53fedf8b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 16:41:50 +0200 Subject: [PATCH] docs(validation): document the XSD validator scope decision; isolation-test each category The validation framework already conforms to the OpenAPIKit idiom: named composable Validation values, a Validator with blank/validating/ withoutValidating, located errors, parameterized over both subject and document. The DTD layer is already four named rules. XSD content validation stays a single recursive Validation (contentValidity) rather than a rule per constraint, because XSD is a recursive type system: a child's type is resolved from its parent's content model and position, then rewritten by xsi:type and the derivation controls mid-walk, so the constraint categories are interdependent and cannot be split into independent rules without changing semantics or duplicating the recursion. That is the sanctioned multi-error form. To keep each category verifiable on its own (the idiom's testability), add XSDCategoryTests exercising ComplexValidator directly per category (attributes, content, nillable, fixed); derivation, wildcards, and namespaces are already isolation-tested through crafted schema strings. Closes #101 --- Sources/Schema/XSDValidations.swift | 21 +++++-- Tests/XSDCategoryTests.swift | 98 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Tests/XSDCategoryTests.swift diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift index d03e871..01a39c8 100644 --- a/Sources/Schema/XSDValidations.swift +++ b/Sources/Schema/XSDValidations.swift @@ -34,10 +34,23 @@ public extension PureXML.Validation { } /// 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. + /// ``XSDContext``, in the OpenAPIKit idiom: each rule is a named, removable + /// ``Validation`` value with a positive description, and the ``validator()`` + /// composes them; failures are located ``ValidationError``s. + /// + /// Scope note (the #101 decision). XSD content validation is *one* recursive + /// ``Validation`` (``contentValidity``) rather than a separate rule per + /// constraint, because XSD is a recursive type system: a child's type is + /// resolved from its parent's content model and position, then rewritten by + /// `xsi:type` and the derivation controls mid-walk. The constraint categories + /// (attributes, content model, derivation, nillability, fixed values) are + /// interdependent through that resolution, so they cannot be split into + /// independent rules without either changing semantics or duplicating the + /// recursion. This is the sanctioned multi-error form (the rule's `check` + /// returns one located error per violation). Each category is isolation-tested + /// through crafted inputs so it is verifiable on its own, which is the + /// testability the idiom asks for. Identity constraints, which *are* + /// independent, are a separate rule (``identityConstraints``). enum XSD { /// The document element is valid against its declared XSD type. static var contentValidity: Validation { diff --git a/Tests/XSDCategoryTests.swift b/Tests/XSDCategoryTests.swift new file mode 100644 index 0000000..90ddefe --- /dev/null +++ b/Tests/XSDCategoryTests.swift @@ -0,0 +1,98 @@ +@testable import PureXML +import Testing + +/// Each XSD constraint category exercised against `ComplexValidator` directly with +/// crafted inputs, so it is verifiable on its own (the OpenAPIKit isolation recipe +/// applied to the inherently-recursive XSD validator; see XSDValidations.swift's +/// #101 scope note). Derivation, wildcards, and namespaces are isolation-tested +/// through crafted schema strings in their own suites. +@Suite("XSD constraint categories in isolation") +struct XSDCategoryTests { + private typealias Schema = PureXML.Schema + private typealias Name = PureXML.Model.QualifiedName + + private func element(_ name: String, _ attributes: [(String, String)] = [], children: [PureXML.Model.Node] = []) -> PureXML.Model.Element { + PureXML.Model.Element(name, attributes: attributes.map { PureXML.Model.Attribute($0.0, $0.1) }, children: children) + } + + private func validate( + _ element: PureXML.Model.Element, + _ type: Schema.ComplexType, + validator: Schema.ComplexValidator = Schema.ComplexValidator(), + ) -> [PureXML.Validation.ValidationError] { + validator.validate(element, against: type, at: [.element(element.name.description)]) + } + + // MARK: Attributes + + @Test("Attributes: a required attribute is missing") + func test_requiredMissing() { + let type = Schema.ComplexType(attributes: [Schema.AttributeUse(name: Name("id"), type: Schema.SimpleType(base: .string), required: true)], content: .empty) + let errors = validate(element("a"), type) + #expect(errors.count == 1) + #expect(errors.first?.reason == "missing required attribute 'id'") + #expect(errors.first?.codingPath.map(\.stringValue) == ["a"]) + } + + @Test("Attributes: a fixed attribute must match") + func test_fixedAttribute() { + let use = Schema.AttributeUse(name: Name("v"), type: Schema.SimpleType(base: .string), valueConstraint: .fixed("1")) + let type = Schema.ComplexType(attributes: [use], content: .empty) + #expect(validate(element("a", [("v", "1")]), type).isEmpty) + #expect(validate(element("a", [("v", "2")]), type).first?.reason.contains("fixed") == true) + } + + @Test("Attributes: an undeclared attribute is rejected unless a wildcard admits it") + func test_undeclaredAttribute() { + let strict = Schema.ComplexType(content: .empty) + #expect(validate(element("a", [("x", "1")]), strict).first?.reason == "undeclared attribute 'x'") + let lax = Schema.ComplexType(attributeWildcard: Schema.Wildcard(namespace: .any, processContents: .skip), content: .empty) + #expect(validate(element("a", [("x", "1")]), lax).isEmpty) + } + + // MARK: Content + + @Test("Content: an EMPTY type rejects children") + func test_emptyWithChildren() { + let type = Schema.ComplexType(content: .empty) + #expect(validate(element("a", children: [.element(element("b"))]), type).first?.reason == "element must be empty") + } + + @Test("Content: simpleContent validates the text against its datatype") + func test_simpleContent() { + let type = Schema.ComplexType(content: .simpleContent(Schema.SimpleType(base: .integer))) + #expect(validate(element("a", children: [.text("5")]), type).isEmpty) + #expect(!validate(element("a", children: [.text("x")]), type).isEmpty) + } + + @Test("Content: a child outside the content model is a model mismatch") + func test_contentModelMismatch() { + let particle = Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: [ + Schema.Particle(term: .element(name: Name("b"), type: nil)), + ]))) + let type = Schema.ComplexType(content: .elementOnly(particle)) + #expect(validate(element("a", children: [.element(element("b"))]), type).isEmpty) + #expect(validate(element("a", children: [.element(element("c"))]), type).first?.reason == "content does not match the content model") + } + + // MARK: Nillable and fixed + + @Test("Nillable: xsi:nil on a non-nillable element is rejected") + func test_notNillable() { + let xsi = ("xsi:nil", "true") + let type = Schema.ComplexType(content: .empty) + // Default validator declares no nillable elements. + #expect(validate(element("a", [xsi]), type).contains { $0.reason.contains("not nillable") }) + // A validator that declares it nillable accepts the empty nilled element. + let nillable = Schema.ComplexValidator(nillableElements: ["a"]) + #expect(validate(element("a", [xsi]), type, validator: nillable).isEmpty) + } + + @Test("Fixed: an element fixed value must match") + func test_fixedElement() { + let validator = Schema.ComplexValidator(elementConstraints: ["a": .fixed("yes")]) + let type = Schema.ComplexType(content: .simpleContent(Schema.SimpleType(base: .string))) + #expect(validate(element("a", children: [.text("yes")]), type, validator: validator).isEmpty) + #expect(validate(element("a", children: [.text("no")]), type, validator: validator).contains { $0.reason.contains("fixed") }) + } +}