Merge XSD nillable + value constraints (#75)
This commit is contained in:
commit
df1d93d21b
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
|
||||
78
Tests/XSDValueConstraintTests.swift
Normal file
78
Tests/XSDValueConstraintTests.swift
Normal 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'"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user