diff --git a/Sources/Schema/ComplexType.swift b/Sources/Schema/ComplexType.swift index 8777d9c..0aeb312 100644 --- a/Sources/Schema/ComplexType.swift +++ b/Sources/Schema/ComplexType.swift @@ -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 } } diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 992a419..0463500 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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 + /// 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 = [], + 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 } } + } +} diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index 132e32d..7efdb25 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -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 + 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) } } diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index c08fa9e..5253b80 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -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 = [] + /// 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 diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 0f6eab9..263e0a4 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -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: PureXML.Schema.ValueConstraint]) { + var nillable: Set = [] + 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 + } +} diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift index 27ba99c..1bffc54 100644 --- a/Sources/Schema/XSDValidations.swift +++ b/Sources/Schema/XSDValidations.swift @@ -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 + 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 = [], + 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 }, ) diff --git a/Tests/XSDValueConstraintTests.swift b/Tests/XSDValueConstraintTests.swift new file mode 100644 index 0000000..2675eb6 --- /dev/null +++ b/Tests/XSDValueConstraintTests.swift @@ -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 = """ + + + + """ + #expect(try validate(xsd, "").isEmpty) + // Even though the content would otherwise have to be an integer. + #expect(try validate(xsd, "notanumber").isEmpty == false) + } + + @Test("A nilled element must be empty") + func test_nilMustBeEmpty() throws { + let xsd = """ + + + + """ + let found = try validate(xsd, "5") + #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 = """ + + + + """ + let found = try validate(xsd, "") + #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 = """ + + + + + + + + """ + #expect(try validate(xsd, "").isEmpty) + #expect(try validate(xsd, "").isEmpty) + let found = try validate(xsd, "") + #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 = """ + + + + """ + #expect(try validate(xsd, "1.0").isEmpty) + let found = try validate(xsd, "2.0") + #expect(found.count == 1) + #expect(found[0].reason.contains("is fixed and must be '1.0'")) + } +}