diff --git a/Sources/Schema/IdentityConstraint.swift b/Sources/Schema/IdentityConstraint.swift
new file mode 100644
index 0000000..2e7c356
--- /dev/null
+++ b/Sources/Schema/IdentityConstraint.swift
@@ -0,0 +1,31 @@
+public extension PureXML.Schema {
+ /// The kind of an ``IdentityConstraint``, and for a keyref the name of the
+ /// key it refers to.
+ enum IdentityConstraintKind: Sendable, Equatable {
+ /// Tuples must be distinct where present; a node missing a field is
+ /// excluded rather than rejected.
+ case unique
+ /// Tuples must be distinct and every field must be present.
+ case key
+ /// Every tuple must equal a tuple of the named key or unique in scope.
+ case keyref(refer: String)
+ }
+
+ /// An XSD identity constraint declared on an element: `xs:unique`, `xs:key`,
+ /// or `xs:keyref`. The `selector` XPath chooses the nodes the constraint
+ /// ranges over (relative to the declaring element), and each `field` XPath
+ /// extracts one component of the value tuple from a selected node.
+ struct IdentityConstraint: Sendable {
+ public var name: String
+ public var kind: IdentityConstraintKind
+ public var selector: String
+ public var fields: [String]
+
+ public init(name: String, kind: IdentityConstraintKind, selector: String, fields: [String]) {
+ self.name = name
+ self.kind = kind
+ self.selector = selector
+ self.fields = fields
+ }
+ }
+}
diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift
new file mode 100644
index 0000000..5554aa4
--- /dev/null
+++ b/Sources/Schema/IdentityValidator.swift
@@ -0,0 +1,111 @@
+extension PureXML.Schema {
+ /// Validates XSD identity constraints (`unique`, `key`, `keyref`) over an
+ /// instance document. Each constraint is evaluated at every element whose
+ /// name carries it: the selector XPath chooses target nodes relative to that
+ /// element, and the field XPaths form each target's value tuple. `key` and
+ /// `unique` require distinct tuples; `keyref` requires each tuple to match a
+ /// key or unique of the named constraint visible in an enclosing scope.
+ struct IdentityValidator {
+ /// The constraints declared for each element local name.
+ let constraints: [String: [IdentityConstraint]]
+
+ func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.Issue] {
+ guard !constraints.isEmpty else { return [] }
+ var issues: [PureXML.Validation.Issue] = []
+ walk(root, scopes: [], into: &issues)
+ return issues
+ }
+
+ private func walk(
+ _ node: PureXML.Model.TreeNode,
+ scopes: [[String: [[String?]]]],
+ into issues: inout [PureXML.Validation.Issue],
+ ) {
+ var frame: [String: [[String?]]] = [:]
+ let declared = node.name.flatMap { constraints[$0.localName] } ?? []
+ // Collect key and unique tuples first so a keyref on the same element
+ // can resolve against them.
+ for constraint in declared {
+ collect(constraint, at: node, frame: &frame, into: &issues)
+ }
+ let visible = scopes + [frame]
+ for constraint in declared {
+ if case let .keyref(refer) = constraint.kind {
+ checkKeyref(constraint, refer: refer, at: node, scopes: visible, into: &issues)
+ }
+ }
+ for child in node.children where child.kind == .element {
+ walk(child, scopes: visible, into: &issues)
+ }
+ }
+
+ private func isNonRef(_ constraint: IdentityConstraint) -> Bool {
+ if case .keyref = constraint.kind { return false }
+ return true
+ }
+
+ private func collect(
+ _ constraint: IdentityConstraint,
+ at node: PureXML.Model.TreeNode,
+ frame: inout [String: [[String?]]],
+ into issues: inout [PureXML.Validation.Issue],
+ ) {
+ guard isNonRef(constraint) else { return }
+ var tuples: [[String?]] = []
+ var seen: [[String?]] = []
+ for target in select(constraint.selector, at: node) {
+ let tuple = fieldTuple(constraint.fields, at: target)
+ if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
+ issues.append(.init(severity: .error, message: "key '\(constraint.name)': a field is missing"))
+ continue
+ }
+ if tuple.contains(where: { $0 == nil }) { continue }
+ if seen.contains(where: { $0 == tuple }) {
+ issues.append(.init(severity: .error, message: "\(label(constraint)) '\(constraint.name)': duplicate value"))
+ } else {
+ seen.append(tuple)
+ }
+ tuples.append(tuple)
+ }
+ frame[constraint.name] = tuples
+ }
+
+ private func checkKeyref(
+ _ constraint: IdentityConstraint,
+ refer: String,
+ at node: PureXML.Model.TreeNode,
+ scopes: [[String: [[String?]]]],
+ into issues: inout [PureXML.Validation.Issue],
+ ) {
+ let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
+ for target in select(constraint.selector, at: node) {
+ let tuple = fieldTuple(constraint.fields, at: target)
+ if tuple.contains(where: { $0 == nil }) { continue }
+ if !keyTuples.contains(where: { $0 == tuple }) {
+ issues.append(.init(severity: .error, message: "keyref '\(constraint.name)': no matching key '\(refer)'"))
+ }
+ }
+ }
+
+ // MARK: XPath helpers
+
+ private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
+ guard let query = try? PureXML.XPath.Query(xpath) else { return [] }
+ return query.nodes(over: node)
+ }
+
+ private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode) -> [String?] {
+ fields.map { field in
+ guard let query = try? PureXML.XPath.Query(field), let value = try? query.value(at: node) else {
+ return nil
+ }
+ if let nodes = value.nodes, nodes.isEmpty { return nil }
+ return value.string
+ }
+ }
+
+ private func label(_ constraint: IdentityConstraint) -> String {
+ constraint.kind == .key ? "key" : "unique"
+ }
+ }
+}
diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift
index 64763b2..df2c4bb 100644
--- a/Sources/Schema/SchemaDocument.swift
+++ b/Sources/Schema/SchemaDocument.swift
@@ -15,6 +15,7 @@ public extension PureXML.Schema {
struct Document: Sendable {
private let elements: [String: ElementType]
private let types: [String: ElementType]
+ private let constraints: [String: [IdentityConstraint]]
/// Compiles a schema document. `schemaLoader` resolves the
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
@@ -22,7 +23,10 @@ public extension PureXML.Schema {
/// not available, which keeps compilation from reaching the filesystem or
/// network by default.
public init(_ xsd: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
- (elements, types) = try XSDParser.parse(xsd, loader: schemaLoader)
+ let compiled = try XSDParser.parse(xsd, loader: schemaLoader)
+ elements = compiled.elements
+ types = compiled.types
+ constraints = compiled.constraints
}
/// Validates an instance document against the schema, returning one issue
@@ -37,7 +41,9 @@ public extension PureXML.Schema {
guard let declaration = elements[root.name.localName] else {
return [.init(severity: .error, message: "no element declaration for '\(root.name.localName)'")]
}
- return ComplexValidator(types: types).validate(root, as: declaration)
+ var issues = ComplexValidator(types: types).validate(root, as: declaration)
+ issues += IdentityValidator(constraints: constraints).validate(PureXML.Model.TreeNode(.element(root)))
+ return issues
}
}
}
diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift
index 6160f95..c08fa9e 100644
--- a/Sources/Schema/XSDContext.swift
+++ b/Sources/Schema/XSDContext.swift
@@ -94,6 +94,15 @@ extension PureXML.Schema {
}
}
+ /// The result of compiling a schema document: its global element
+ /// declarations, its named-type table, and the identity constraints declared
+ /// on each element name.
+ struct XSDCompiled {
+ var elements: [String: ElementType]
+ var types: [String: ElementType]
+ var constraints: [String: [IdentityConstraint]]
+ }
+
/// The parsing context: the named simple types resolved so far, plus the
/// definition nodes for named attribute groups and model groups (so their
/// refs can be expanded), and a guard against cyclic group references.
diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift
index f603b25..0f6eab9 100644
--- a/Sources/Schema/XSDParser.swift
+++ b/Sources/Schema/XSDParser.swift
@@ -7,10 +7,7 @@ extension PureXML.Schema {
/// content, the full facet set, `list` and `union` simple types, named
/// attribute groups and model groups, and element and group references.
enum XSDParser {
- static func parse(
- _ xsd: String,
- loader: (String) -> String? = { _ in nil },
- ) throws -> (elements: [String: ElementType], types: [String: ElementType]) {
+ static func parse(_ xsd: String, loader: (String) -> String? = { _ in nil }) throws -> XSDCompiled {
let root = try PureXML.parseTree(xsd)
guard let schema = XSDNode.elementChildren(root).first(where: { XSDNode.localName($0) == "schema" }) else {
throw PureXML.Schema.SchemaError.notASchema
@@ -53,7 +50,43 @@ extension PureXML.Schema {
types[elementKey(name)] = type
}
}
- return (elements, types)
+ 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
}
private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] {
diff --git a/Tests/XSDIdentityTests.swift b/Tests/XSDIdentityTests.swift
new file mode 100644
index 0000000..e84df74
--- /dev/null
+++ b/Tests/XSDIdentityTests.swift
@@ -0,0 +1,81 @@
+@testable import PureXML
+import Testing
+
+@Suite("XSD identity constraints")
+struct XSDIdentityTests {
+ private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.Issue] {
+ try PureXML.Schema.Document(xsd).validate(xml)
+ }
+
+ private let head = ""
+
+ @Test("xs:unique rejects a duplicated field value")
+ func test_unique() throws {
+ let xsd = """
+ \(head)
+
+
+
+
+
+
+
+
+
+
+ """
+ #expect(try validate(xsd, "
").isEmpty)
+ #expect(try !validate(xsd, "
").isEmpty)
+ // unique tolerates a missing field
+ #expect(try validate(xsd, "
").isEmpty)
+ }
+
+ @Test("xs:key requires every field to be present and distinct")
+ func test_key() throws {
+ let xsd = """
+ \(head)
+
+
+
+
+
+
+
+
+
+
+ """
+ #expect(try validate(xsd, "
").isEmpty)
+ #expect(try !validate(xsd, "
").isEmpty)
+ // a key field may not be absent
+ #expect(try !validate(xsd, "
").isEmpty)
+ }
+
+ @Test("xs:keyref requires a matching key value")
+ func test_keyref() throws {
+ let xsd = """
+ \(head)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ let valid = ""
+ let dangling = ""
+ #expect(try validate(xsd, valid).isEmpty)
+ #expect(try !validate(xsd, dangling).isEmpty)
+ }
+}