Merge XSD nillable + value constraints (#75)

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 13:03:07 +02:00
commit df1d93d21b
7 changed files with 265 additions and 43 deletions

View File

@ -57,16 +57,36 @@ public extension PureXML.Schema {
case typeReference(String)
}
/// A value constraint on an attribute or element: a default value supplied
/// when the item is absent, or a fixed value the item must equal.
enum ValueConstraint: Sendable, Equatable {
case `default`(String)
case fixed(String)
/// The fixed value, when this is a `fixed` constraint.
public var fixedValue: String? {
if case let .fixed(value) = self { return value }
return nil
}
}
/// An attribute use on a complex type.
struct AttributeUse: Sendable {
public var name: PureXML.Model.QualifiedName
public var type: SimpleType
public var required: Bool
public var valueConstraint: ValueConstraint?
public init(name: PureXML.Model.QualifiedName, type: SimpleType, required: Bool = false) {
public init(
name: PureXML.Model.QualifiedName,
type: SimpleType,
required: Bool = false,
valueConstraint: ValueConstraint? = nil,
) {
self.name = name
self.type = type
self.required = required
self.valueConstraint = valueConstraint
}
}

View File

@ -11,9 +11,19 @@ public extension PureXML.Schema {
struct ComplexValidator {
/// The named type table that `ElementType.typeReference` resolves against.
private let types: [String: ElementType]
/// Local names of elements declared `nillable="true"`.
private let nillableElements: Set<String>
/// The `default`/`fixed` value constraint declared on each element name.
private let elementConstraints: [String: ValueConstraint]
public init(types: [String: ElementType] = [:]) {
public init(
types: [String: ElementType] = [:],
nillableElements: Set<String> = [],
elementConstraints: [String: ValueConstraint] = [:],
) {
self.types = types
self.nillableElements = nillableElements
self.elementConstraints = elementConstraints
}
/// Validates `element` against `type` at `path`, one error per violation.
@ -24,10 +34,39 @@ public extension PureXML.Schema {
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateAttributes(element, type, at: path, into: &errors)
// An xsi:nil element keeps its attribute obligations but must have no
// content; its content model is not otherwise checked.
if let nilErrors = nilErrors(element, at: path) {
return errors + nilErrors
}
validateContent(element, type.content, at: path, into: &errors)
errors += elementFixedErrors(element, at: path)
return errors
}
/// The errors from an `xsi:nil="true"` element: rejecting it when the
/// element is not nillable, or when it carries content. Returns nil when
/// the element is not nilled.
private func nilErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure]? {
guard Self.isNil(element) else { return nil }
let name = element.name.localName
if !nillableElements.contains(name) {
return [XSDFailure(reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
}
if Self.hasContent(element) {
return [XSDFailure(reason: "nil element '\(name)' must be empty", at: path)]
}
return []
}
/// The error from a `fixed` element value constraint: the element's text
/// must equal the fixed value.
private func elementFixedErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure] {
guard let fixed = elementConstraints[element.name.localName]?.fixedValue else { return [] }
let text = Self.textContent(element)
return text == fixed ? [] : [XSDFailure(reason: "element '\(element.name.localName)' is fixed and must be '\(fixed)'", at: path)]
}
/// Validates `element` against any element type at `path`, resolving a
/// `typeReference` through the type table.
public func validate(
@ -55,6 +94,9 @@ public extension PureXML.Schema {
if let error = use.type.validate(match.value) {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, match.value != fixed {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
} else if use.required {
errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path))
}
@ -145,12 +187,17 @@ public extension PureXML.Schema {
}
switch declared {
case let .simple(simple):
if let nilErrors = nilErrors(child, at: path) {
errors += nilErrors
return
}
if !child.children.compactMap(\.element).isEmpty {
errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path))
}
if let error = simple.validate(Self.textContent(child)) {
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
}
errors += elementFixedErrors(child, at: path)
case let .complex(complex):
errors.append(contentsOf: validate(child, against: complex, at: path))
case let .typeReference(name):
@ -260,3 +307,19 @@ public extension PureXML.Schema {
}
}
}
extension PureXML.Schema.ComplexValidator {
/// Whether the element carries `xsi:nil="true"`.
static func isNil(_ element: PureXML.Model.Element) -> Bool {
element.attributes.contains { attribute in
attribute.name.localName == "nil"
&& (attribute.name.namespaceURI == "http://www.w3.org/2001/XMLSchema-instance" || attribute.name.prefix == "xsi")
&& attribute.value == "true"
}
}
/// Whether the element has any child element or non-whitespace text.
static func hasContent(_ element: PureXML.Model.Element) -> Bool {
!textContent(element).isEmpty || element.children.contains { if case .element = $0 { true } else { false } }
}
}

View File

@ -16,6 +16,8 @@ public extension PureXML.Schema {
private let elements: [String: ElementType]
private let types: [String: ElementType]
private let constraints: [String: [IdentityConstraint]]
private let nillableElements: Set<String>
private let elementConstraints: [String: ValueConstraint]
/// Compiles a schema document. `schemaLoader` resolves the
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
@ -27,6 +29,8 @@ public extension PureXML.Schema {
elements = compiled.elements
types = compiled.types
constraints = compiled.constraints
nillableElements = compiled.nillableElements
elementConstraints = compiled.elementConstraints
}
/// Validates an instance document against the schema, returning one located
@ -41,7 +45,13 @@ public extension PureXML.Schema {
guard let declaration = elements[root.name.localName] else {
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])]
}
let context = PureXML.Validation.XSDContext(types: types, constraints: constraints, rootDeclaration: declaration)
let context = PureXML.Validation.XSDContext(
types: types,
constraints: constraints,
rootDeclaration: declaration,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
}

View File

@ -101,6 +101,10 @@ extension PureXML.Schema {
var elements: [String: ElementType]
var types: [String: ElementType]
var constraints: [String: [IdentityConstraint]]
/// Local names of elements declared `nillable="true"`.
var nillableElements: Set<String> = []
/// The `default`/`fixed` value constraint declared on each element name.
var elementConstraints: [String: ValueConstraint] = [:]
}
/// The parsing context: the named simple types resolved so far, plus the

View File

@ -50,43 +50,14 @@ extension PureXML.Schema {
types[elementKey(name)] = type
}
}
return XSDCompiled(elements: elements, types: types, constraints: identityConstraints(containers))
}
/// Gathers identity constraints (`unique`, `key`, `keyref`) declared on
/// any element at any depth, keyed by the element's name.
private static func identityConstraints(_ containers: [XSDTree]) -> [String: [IdentityConstraint]] {
var map: [String: [IdentityConstraint]] = [:]
for container in containers {
for element in descendants(container, named: "element") {
guard let name = XSDNode.attribute(element, "name") else { continue }
let constraints = XSDNode.elementChildren(element).compactMap(constraint)
if !constraints.isEmpty { map[name, default: []] += constraints }
}
}
return map
}
private static func constraint(_ node: XSDTree) -> IdentityConstraint? {
let kind: IdentityConstraintKind
switch XSDNode.localName(node) {
case "unique": kind = .unique
case "key": kind = .key
case "keyref": kind = .keyref(refer: XSDNode.stripPrefix(XSDNode.attribute(node, "refer") ?? ""))
default: return nil
}
let selector = XSDNode.firstChild(node, named: "selector").flatMap { XSDNode.attribute($0, "xpath") } ?? ""
let fields = XSDNode.children(node, named: "field").compactMap { XSDNode.attribute($0, "xpath") }
return IdentityConstraint(name: XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields)
}
private static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] {
var result: [XSDTree] = []
for child in XSDNode.elementChildren(node) {
if XSDNode.localName(child) == name { result.append(child) }
result += descendants(child, named: name)
}
return result
let (nillable, elementConstraints) = elementMetadata(containers)
return XSDCompiled(
elements: elements,
types: types,
constraints: identityConstraints(containers),
nillableElements: nillable,
elementConstraints: elementConstraints,
)
}
private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] {
@ -196,7 +167,20 @@ extension PureXML.Schema {
} else {
SimpleType(base: .string)
}
return AttributeUse(name: PureXML.Model.QualifiedName(name), type: type, required: required)
return AttributeUse(
name: PureXML.Model.QualifiedName(name),
type: type,
required: required,
valueConstraint: valueConstraint(of: node),
)
}
/// The `default` or `fixed` value constraint declared on an attribute or
/// element node, if any (`fixed` takes precedence).
static func valueConstraint(of node: XSDTree) -> ValueConstraint? {
if let fixed = XSDNode.attribute(node, "fixed") { return .fixed(fixed) }
if let value = XSDNode.attribute(node, "default") { return .default(value) }
return nil
}
// MARK: Model groups
@ -287,3 +271,56 @@ extension PureXML.Schema {
}
}
}
extension PureXML.Schema.XSDParser {
/// Gathers `nillable` and `default`/`fixed` value constraints from every
/// element declaration at any depth, keyed by the element's name.
static func elementMetadata(_ containers: [XSDTree]) -> (Set<String>, [String: PureXML.Schema.ValueConstraint]) {
var nillable: Set<String> = []
var constraints: [String: PureXML.Schema.ValueConstraint] = [:]
for container in containers {
for element in descendants(container, named: "element") {
guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue }
if PureXML.Schema.XSDNode.attribute(element, "nillable") == "true" { nillable.insert(name) }
if let constraint = valueConstraint(of: element) { constraints[name] = constraint }
}
}
return (nillable, constraints)
}
/// Gathers identity constraints (`unique`, `key`, `keyref`) declared on any
/// element at any depth, keyed by the element's name.
static func identityConstraints(_ containers: [XSDTree]) -> [String: [PureXML.Schema.IdentityConstraint]] {
var map: [String: [PureXML.Schema.IdentityConstraint]] = [:]
for container in containers {
for element in descendants(container, named: "element") {
guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue }
let constraints = PureXML.Schema.XSDNode.elementChildren(element).compactMap(constraint)
if !constraints.isEmpty { map[name, default: []] += constraints }
}
}
return map
}
private static func constraint(_ node: XSDTree) -> PureXML.Schema.IdentityConstraint? {
let kind: PureXML.Schema.IdentityConstraintKind
switch PureXML.Schema.XSDNode.localName(node) {
case "unique": kind = .unique
case "key": kind = .key
case "keyref": kind = .keyref(refer: PureXML.Schema.XSDNode.stripPrefix(PureXML.Schema.XSDNode.attribute(node, "refer") ?? ""))
default: return nil
}
let selector = PureXML.Schema.XSDNode.firstChild(node, named: "selector").flatMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } ?? ""
let fields = PureXML.Schema.XSDNode.children(node, named: "field").compactMap { PureXML.Schema.XSDNode.attribute($0, "xpath") }
return PureXML.Schema.IdentityConstraint(name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields)
}
private static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] {
var result: [XSDTree] = []
for child in PureXML.Schema.XSDNode.elementChildren(node) {
if PureXML.Schema.XSDNode.localName(child) == name { result.append(child) }
result += descendants(child, named: name)
}
return result
}
}

View File

@ -6,15 +6,21 @@ public extension PureXML.Validation {
public let types: [String: PureXML.Schema.ElementType]
public let constraints: [String: [PureXML.Schema.IdentityConstraint]]
public let rootDeclaration: PureXML.Schema.ElementType?
public let nillableElements: Set<String>
public let elementConstraints: [String: PureXML.Schema.ValueConstraint]
public init(
types: [String: PureXML.Schema.ElementType],
constraints: [String: [PureXML.Schema.IdentityConstraint]],
rootDeclaration: PureXML.Schema.ElementType?,
nillableElements: Set<String> = [],
elementConstraints: [String: PureXML.Schema.ValueConstraint] = [:],
) {
self.types = types
self.constraints = constraints
self.rootDeclaration = rootDeclaration
self.nillableElements = nillableElements
self.elementConstraints = elementConstraints
}
}
@ -32,8 +38,12 @@ public extension PureXML.Validation {
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)])
return PureXML.Schema.ComplexValidator(
types: context.document.types,
nillableElements: context.document.nillableElements,
elementConstraints: context.document.elementConstraints,
)
.validate(root, as: declaration, at: [.element(root.name.description)])
},
when: { $0.codingPath.isEmpty },
)

View File

@ -0,0 +1,78 @@
@testable import PureXML
import Testing
@Suite("XSD nillable and value constraints")
struct XSDValueConstraintTests {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd).validate(xml)
}
private let xsi = "xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
@Test("A nillable element accepts xsi:nil with empty content")
func test_nillableEmpty() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="age" type="xs:integer" nillable="true"/>
</xs:schema>
"""
#expect(try validate(xsd, "<age \(xsi) xsi:nil=\"true\"/>").isEmpty)
// Even though the content would otherwise have to be an integer.
#expect(try validate(xsd, "<age>notanumber</age>").isEmpty == false)
}
@Test("A nilled element must be empty")
func test_nilMustBeEmpty() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="age" type="xs:integer" nillable="true"/>
</xs:schema>
"""
let found = try validate(xsd, "<age \(xsi) xsi:nil=\"true\">5</age>")
#expect(found.count == 1)
#expect(found[0].reason.contains("must be empty"))
}
@Test("xsi:nil on a non-nillable element is rejected")
func test_nilNotNillable() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="age" type="xs:integer"/>
</xs:schema>
"""
let found = try validate(xsd, "<age \(xsi) xsi:nil=\"true\"/>")
#expect(found.count == 1)
#expect(found[0].reason.contains("not nillable"))
}
@Test("A fixed attribute must carry its fixed value")
func test_fixedAttribute() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="item">
<xs:complexType>
<xs:attribute name="kind" type="xs:string" fixed="book"/>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<item kind=\"book\"/>").isEmpty)
#expect(try validate(xsd, "<item/>").isEmpty)
let found = try validate(xsd, "<item kind=\"film\"/>")
#expect(found.count == 1)
#expect(found[0].reason.contains("is fixed and must be 'book'"))
}
@Test("A fixed element value must match")
func test_fixedElement() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="version" type="xs:string" fixed="1.0"/>
</xs:schema>
"""
#expect(try validate(xsd, "<version>1.0</version>").isEmpty)
let found = try validate(xsd, "<version>2.0</version>")
#expect(found.count == 1)
#expect(found[0].reason.contains("is fixed and must be '1.0'"))
}
}