From fe19b0b1eac4e1fa7ecd860179c698135318a93a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 08:58:36 +0200 Subject: [PATCH] feat(xsd): add identity constraints (unique, key, keyref) Validate XSD identity constraints over the instance document in a pass that walks the tree alongside type validation: - xs:unique requires the selected nodes' field tuples to be distinct, excluding any node that is missing a field. - xs:key additionally requires every field to be present. - xs:keyref requires each tuple to equal a tuple of the named key or unique visible in an enclosing scope, resolved through a stack of scope frames so a keyref and the key it refers to may sit on the same element. The selector and field XPaths run on the existing XPath engine, relative to the declaring element and each selected node. Constraints are gathered for every element declaration at any depth, keyed by element name. Closes #67 --- Sources/Schema/IdentityConstraint.swift | 31 +++++++ Sources/Schema/IdentityValidator.swift | 111 ++++++++++++++++++++++++ Sources/Schema/SchemaDocument.swift | 10 ++- Sources/Schema/XSDContext.swift | 9 ++ Sources/Schema/XSDParser.swift | 43 +++++++-- Tests/XSDIdentityTests.swift | 81 +++++++++++++++++ 6 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 Sources/Schema/IdentityConstraint.swift create mode 100644 Sources/Schema/IdentityValidator.swift create mode 100644 Tests/XSDIdentityTests.swift 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) + } +}