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) + } +}