feat(schema): XSD namespace qualification and xs:all constraints
Make XSD validation namespace-aware and enforce the form defaults and xs:all structural constraints that were previously ignored: - targetNamespace is tracked: a schema's global elements are in its target namespace, and an instance's root element must be in it. - elementFormDefault / attributeFormDefault (and per-declaration form) qualify local element and attribute declarations. The content-model matcher already compares namespace URIs, so qualified declarations now match only same-namespace instance nodes; attribute matching became namespace-aware too. - xs:all groups are checked at compile time: every member must be an element occurring at most once, the group may occur at most once, and nested model groups inside an all are rejected (a SchemaError). When a schema has no target namespace the stamping is a no-op, so unqualified schemas validate exactly as before. Cross-namespace type references through xs:import resolve by local name only; full multi-namespace type tables are not modeled. Closes #78
This commit is contained in:
parent
23b7c081fc
commit
2b7138ab61
@ -107,7 +107,7 @@ public extension PureXML.Schema {
|
||||
) {
|
||||
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 }
|
||||
let match = present.first { Self.sameName($0.name, use.name) }
|
||||
if let match {
|
||||
if let error = use.type.validate(match.value) {
|
||||
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
|
||||
@ -121,7 +121,7 @@ public extension PureXML.Schema {
|
||||
}
|
||||
// An undeclared attribute is allowed only if an xs:anyAttribute wildcard
|
||||
// admits its namespace.
|
||||
for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) {
|
||||
for attribute in present where !type.attributes.contains(where: { Self.sameName($0.name, attribute.name) }) {
|
||||
if type.attributeWildcard?.admits(attribute.name) == true { continue }
|
||||
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
|
||||
}
|
||||
|
||||
@ -89,6 +89,13 @@ extension PureXML.Schema.ComplexValidator {
|
||||
"{\(name.namespaceURI ?? "")}\(name.localName)"
|
||||
}
|
||||
|
||||
/// Whether two names match by namespace and local name. Used to match an
|
||||
/// instance attribute against a declared attribute use, so attributes that
|
||||
/// share a local name in different namespaces are kept distinct.
|
||||
static func sameName(_ lhs: PureXML.Model.QualifiedName, _ rhs: PureXML.Model.QualifiedName) -> Bool {
|
||||
lhs.namespaceURI == rhs.namespaceURI && lhs.localName == rhs.localName
|
||||
}
|
||||
|
||||
static func textContent(_ element: PureXML.Model.Element) -> String {
|
||||
let text = element.children.reduce(into: "") { result, child in
|
||||
switch child {
|
||||
|
||||
@ -7,6 +7,10 @@ public extension PureXML.Schema {
|
||||
/// A type inside `xs:redefine` does not redefine itself: its restriction or
|
||||
/// extension base must name the type it redefines.
|
||||
case redefineIncompatible(type: String)
|
||||
/// An `xs:all` group violates its XSD 1.0 constraints: every member must be
|
||||
/// an element with `maxOccurs` at most 1, and the group itself may occur at
|
||||
/// most once.
|
||||
case invalidAllGroup(reason: String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
@ -16,6 +20,8 @@ public extension PureXML.Schema {
|
||||
"type '\(type)' derives from '\(base)' by \(method), which '\(base)' declares final"
|
||||
case let .redefineIncompatible(type):
|
||||
"redefined type '\(type)' must derive from itself"
|
||||
case let .invalidAllGroup(reason):
|
||||
"invalid xs:all group: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -32,6 +38,7 @@ public extension PureXML.Schema {
|
||||
private let abstractElements: Set<String>
|
||||
private let typeBlock: [String: Set<DerivationMethod>]
|
||||
private let typeDerivation: [String: TypeDerivation]
|
||||
private let targetNamespace: String?
|
||||
|
||||
/// Compiles a schema document. `schemaLoader` resolves the
|
||||
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
|
||||
@ -49,6 +56,7 @@ public extension PureXML.Schema {
|
||||
abstractElements = compiled.abstractElements
|
||||
typeBlock = compiled.typeBlock
|
||||
typeDerivation = compiled.typeDerivation
|
||||
targetNamespace = compiled.targetNamespace
|
||||
}
|
||||
|
||||
/// Validates an instance document against the schema, returning one located
|
||||
@ -67,6 +75,11 @@ public extension PureXML.Schema {
|
||||
guard let declaration = elements[root.name.localName] else {
|
||||
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])]
|
||||
}
|
||||
// A schema with a target namespace declares its global elements in that
|
||||
// namespace; the root must be in it.
|
||||
if let target = targetNamespace, !target.isEmpty, root.name.namespaceURI != target {
|
||||
return [.init(reason: "root element '\(root.name.localName)' is not in the schema target namespace '\(target)'", at: [.element(root.name.description)])]
|
||||
}
|
||||
// An abstract element may not appear in an instance directly; only a
|
||||
// concrete substitution-group member may stand in its place.
|
||||
if abstractElements.contains(root.name.localName) {
|
||||
|
||||
@ -116,6 +116,9 @@ extension PureXML.Schema {
|
||||
/// Each named complex type's base type and derivation method, the backbone
|
||||
/// the `block` check walks from an `xsi:type` to its declared type.
|
||||
var typeDerivation: [String: TypeDerivation] = [:]
|
||||
/// The schema's target namespace, so the root element's namespace can be
|
||||
/// checked against it.
|
||||
var targetNamespace: String?
|
||||
}
|
||||
|
||||
/// The parsing context: the named simple types resolved so far, plus the
|
||||
@ -129,8 +132,15 @@ extension PureXML.Schema {
|
||||
/// resolve and compose its base type's content model and attributes.
|
||||
var complexTypeNodes: [String: XSDTree] = [:]
|
||||
/// The schema's target namespace, for resolving `##other`/`##targetNamespace`
|
||||
/// in wildcard constraints.
|
||||
/// in wildcard constraints and qualifying global and qualified-form local
|
||||
/// declarations.
|
||||
var targetNamespace: String?
|
||||
/// Whether `elementFormDefault="qualified"`: local element declarations are
|
||||
/// in the target namespace unless their own `form` overrides it.
|
||||
var elementFormQualified: Bool = false
|
||||
/// Whether `attributeFormDefault="qualified"`: local attribute declarations
|
||||
/// are in the target namespace unless their own `form` overrides it.
|
||||
var attributeFormQualified: Bool = false
|
||||
/// Each substitution-group head maps to its transitive member element
|
||||
/// names, so an `xs:element ref` to a head also admits its members.
|
||||
var substitutions: [String: [String]] = [:]
|
||||
|
||||
@ -165,4 +165,37 @@ extension PureXML.Schema.XSDParser {
|
||||
case .substitution: "substitution"
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws when any `xs:all` group violates its XSD 1.0 constraints: its members
|
||||
/// must all be elements, each occurring at most once, and the group itself may
|
||||
/// occur at most once. Nested model groups inside an `all` are not allowed.
|
||||
static func checkAllGroups(_ containers: [XSDTree]) throws {
|
||||
for container in containers {
|
||||
for all in descendants(container, named: "all") {
|
||||
try checkAllGroup(all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func checkAllGroup(_ all: XSDTree) throws {
|
||||
if !atMostOnce(XSDDerivNode.attribute(all, "maxOccurs")) {
|
||||
throw SchemaFault.invalidAllGroup(reason: "the group's maxOccurs must be 0 or 1")
|
||||
}
|
||||
for member in XSDDerivNode.elementChildren(all) {
|
||||
let kind = XSDDerivNode.localName(member) ?? ""
|
||||
if kind == "annotation" { continue }
|
||||
guard kind == "element" else {
|
||||
throw SchemaFault.invalidAllGroup(reason: "a member may only be an element, not '\(kind)'")
|
||||
}
|
||||
if !atMostOnce(XSDDerivNode.attribute(member, "maxOccurs")) {
|
||||
let label = XSDDerivNode.attribute(member, "name") ?? XSDDerivNode.attribute(member, "ref") ?? ""
|
||||
throw SchemaFault.invalidAllGroup(reason: "element '\(label)' must have maxOccurs 0 or 1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func atMostOnce(_ maxOccurs: String?) -> Bool {
|
||||
guard let maxOccurs else { return true }
|
||||
return maxOccurs == "0" || maxOccurs == "1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ extension PureXML.Schema {
|
||||
let derivation = derivationTables(containers)
|
||||
try checkFinal(derivation)
|
||||
try checkRedefine(containers)
|
||||
try checkAllGroups(containers)
|
||||
var context = XSDContext(
|
||||
simpleTypes: [:],
|
||||
attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")),
|
||||
@ -25,6 +26,8 @@ extension PureXML.Schema {
|
||||
substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation),
|
||||
abstractElements: derivation.abstractElements,
|
||||
)
|
||||
context.elementFormQualified = XSDNode.attribute(schema, "elementFormDefault") == "qualified"
|
||||
context.attributeFormQualified = XSDNode.attribute(schema, "attributeFormDefault") == "qualified"
|
||||
context.complexTypeNodes = indexByName(allChildren(containers, named: "complexType"))
|
||||
resolveSimpleTypes(allChildren(containers, named: "simpleType"), into: &context)
|
||||
var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple)
|
||||
@ -55,6 +58,7 @@ extension PureXML.Schema {
|
||||
abstractElements: derivation.abstractElements,
|
||||
typeBlock: derivation.typeBlock,
|
||||
typeDerivation: derivation.typeDerivation,
|
||||
targetNamespace: context.targetNamespace,
|
||||
)
|
||||
}
|
||||
|
||||
@ -186,8 +190,10 @@ extension PureXML.Schema {
|
||||
} else {
|
||||
SimpleType(base: .string)
|
||||
}
|
||||
let qualified = XSDNode.attribute(node, "form") == "qualified"
|
||||
|| (XSDNode.attribute(node, "form") == nil && context.attributeFormQualified)
|
||||
return AttributeUse(
|
||||
name: PureXML.Model.QualifiedName(name),
|
||||
name: PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil),
|
||||
type: type,
|
||||
required: required,
|
||||
valueConstraint: valueConstraint(of: node),
|
||||
@ -261,26 +267,22 @@ extension PureXML.Schema {
|
||||
let head = context.abstractElements.contains(name) ? [] : [name]
|
||||
let alternatives = head + (context.substitutions[name] ?? [])
|
||||
if alternatives.count == 1 {
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0]))
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0], context))
|
||||
}
|
||||
// The reference admits its substitution-group members, so expand it
|
||||
// to a choice over the head and every member, each carrying its own
|
||||
// declared type.
|
||||
let members = alternatives.map { Particle(term: elementReferenceTerm($0)) }
|
||||
let members = alternatives.map { Particle(term: elementReferenceTerm($0, context)) }
|
||||
return Particle(minOccurs: minimum, maxOccurs: maximum, term: .group(Group(compositor: .choice, particles: members)))
|
||||
}
|
||||
let name = XSDNode.attribute(node, "name") ?? ""
|
||||
return Particle(
|
||||
minOccurs: minimum,
|
||||
maxOccurs: maximum,
|
||||
term: .element(name: PureXML.Model.QualifiedName(name), type: elementType(node, context)),
|
||||
term: .element(name: localElementName(name, XSDNode.attribute(node, "form"), context), type: elementType(node, context)),
|
||||
)
|
||||
}
|
||||
|
||||
private static func elementReferenceTerm(_ name: String) -> Term {
|
||||
.element(name: PureXML.Model.QualifiedName(name), type: .typeReference(elementKey(name)))
|
||||
}
|
||||
|
||||
private static func groupReferenceParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle? {
|
||||
guard let ref = XSDNode.attribute(node, "ref") else { return nil }
|
||||
let name = XSDNode.stripPrefix(ref)
|
||||
@ -294,6 +296,25 @@ extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Schema.XSDParser {
|
||||
/// The qualified name of a reference to a global element: globals are always in
|
||||
/// the schema's target namespace.
|
||||
static func elementReferenceTerm(_ name: String, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Term {
|
||||
.element(
|
||||
name: PureXML.Model.QualifiedName(localName: name, namespaceURI: context.targetNamespace),
|
||||
type: .typeReference(elementKey(name)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The qualified name of a local element declaration: in the target namespace
|
||||
/// when `elementFormDefault` (or the element's own `form`) is qualified,
|
||||
/// otherwise in no namespace.
|
||||
static func localElementName(_ name: String, _ form: String?, _ context: PureXML.Schema.XSDContext) -> PureXML.Model.QualifiedName {
|
||||
let qualified = form == "qualified" || (form == nil && context.elementFormQualified)
|
||||
return PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Schema.XSDParser {
|
||||
/// Gathers `nillable` and `default`/`fixed` value constraints from every
|
||||
/// element declaration at any depth, keyed by the element's name.
|
||||
|
||||
143
Tests/XSDNamespaceTests.swift
Normal file
143
Tests/XSDNamespaceTests.swift
Normal file
@ -0,0 +1,143 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XSD namespace qualification")
|
||||
struct XSDNamespaceTests {
|
||||
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Schema.Document(xsd).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A root element in the schema target namespace validates")
|
||||
func test_targetNamespaceRoot() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:books">
|
||||
<xs:element name="book" type="xs:string"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(try validate(xsd, "<book xmlns=\"urn:books\">x</book>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A root element not in the target namespace is rejected")
|
||||
func test_targetNamespaceMismatch() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:books">
|
||||
<xs:element name="book" type="xs:string"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
// No namespace on the instance.
|
||||
#expect(try !validate(xsd, "<book>x</book>").isEmpty)
|
||||
// Wrong namespace on the instance.
|
||||
#expect(try !validate(xsd, "<book xmlns=\"urn:other\">x</book>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("elementFormDefault=qualified requires local children in the target namespace")
|
||||
func test_elementFormQualified() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib" elementFormDefault="qualified">
|
||||
<xs:element name="book">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="title" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
// The default namespace qualifies both the root and the local child.
|
||||
#expect(try validate(xsd, "<book xmlns=\"urn:lib\"><title>x</title></book>").isEmpty)
|
||||
// An unqualified child does not match the qualified declaration.
|
||||
let mixed = "<book xmlns:l=\"urn:lib\" xmlns=\"urn:lib\"><title xmlns=\"\">x</title></book>"
|
||||
#expect(try !validate(xsd, mixed).isEmpty)
|
||||
}
|
||||
|
||||
@Test("elementFormDefault=unqualified keeps local children in no namespace")
|
||||
func test_elementFormUnqualified() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib">
|
||||
<xs:element name="book">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="title" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
// The root is qualified; the local child is unqualified, so it must carry no
|
||||
// namespace (the prefixed root keeps the default namespace off the child).
|
||||
let xml = "<l:book xmlns:l=\"urn:lib\"><title>x</title></l:book>"
|
||||
#expect(try validate(xsd, xml).isEmpty)
|
||||
}
|
||||
|
||||
@Test("attributeFormDefault=qualified requires a qualified attribute")
|
||||
func test_attributeFormQualified() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib" elementFormDefault="qualified" attributeFormDefault="qualified">
|
||||
<xs:element name="book">
|
||||
<xs:complexType>
|
||||
<xs:attribute name="id" type="xs:string"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
// A prefixed (qualified) attribute matches the qualified declaration.
|
||||
#expect(try validate(xsd, "<l:book xmlns:l=\"urn:lib\" l:id=\"1\"/>").isEmpty)
|
||||
// An unqualified attribute is undeclared under attributeFormDefault=qualified.
|
||||
#expect(try !validate(xsd, "<l:book xmlns:l=\"urn:lib\" id=\"1\"/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("xs:all with maxOccurs>1 on a member is a schema error")
|
||||
func test_allMemberMaxOccurs() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="rec">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="a" type="xs:string" maxOccurs="2"/>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(throws: PureXML.Schema.SchemaError.self) {
|
||||
_ = try PureXML.Schema.Document(xsd)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("xs:all containing a nested model group is a schema error")
|
||||
func test_allNestedGroup() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="rec">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:sequence>
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(throws: PureXML.Schema.SchemaError.self) {
|
||||
_ = try PureXML.Schema.Document(xsd)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A valid xs:all still compiles and validates order-independently")
|
||||
func test_allValid() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="rec">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
<xs:element name="b" type="xs:string"/>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
"""
|
||||
#expect(try validate(xsd, "<rec><b>2</b><a>1</a></rec>").isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user