Merge epic/67-xsd-identity: unique, key, keyref identity constraints

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 08:58:37 +02:00
commit f2a1e44e5e
6 changed files with 278 additions and 7 deletions

View File

@ -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
}
}
}

View File

@ -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"
}
}
}

View File

@ -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
}
}
}

View File

@ -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.

View File

@ -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] {

View File

@ -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 = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">"
@Test("xs:unique rejects a duplicated field value")
func test_unique() throws {
let xsd = """
\(head)
<xs:element name="list">
<xs:complexType>
<xs:sequence><xs:element name="item" type="xs:string" maxOccurs="unbounded"/></xs:sequence>
</xs:complexType>
<xs:unique name="byId">
<xs:selector xpath="item"/>
<xs:field xpath="@id"/>
</xs:unique>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<list><item id=\"1\"/><item id=\"2\"/></list>").isEmpty)
#expect(try !validate(xsd, "<list><item id=\"1\"/><item id=\"1\"/></list>").isEmpty)
// unique tolerates a missing field
#expect(try validate(xsd, "<list><item/><item/></list>").isEmpty)
}
@Test("xs:key requires every field to be present and distinct")
func test_key() throws {
let xsd = """
\(head)
<xs:element name="list">
<xs:complexType>
<xs:sequence><xs:element name="item" type="xs:string" maxOccurs="unbounded"/></xs:sequence>
</xs:complexType>
<xs:key name="itemKey">
<xs:selector xpath="item"/>
<xs:field xpath="@id"/>
</xs:key>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<list><item id=\"1\"/><item id=\"2\"/></list>").isEmpty)
#expect(try !validate(xsd, "<list><item id=\"1\"/><item id=\"1\"/></list>").isEmpty)
// a key field may not be absent
#expect(try !validate(xsd, "<list><item id=\"1\"/><item/></list>").isEmpty)
}
@Test("xs:keyref requires a matching key value")
func test_keyref() throws {
let xsd = """
\(head)
<xs:element name="orders">
<xs:complexType>
<xs:sequence>
<xs:element name="product" type="xs:string" maxOccurs="unbounded"/>
<xs:element name="line" type="xs:string" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:key name="prodKey">
<xs:selector xpath="product"/>
<xs:field xpath="@code"/>
</xs:key>
<xs:keyref name="lineRef" refer="prodKey">
<xs:selector xpath="line"/>
<xs:field xpath="@product"/>
</xs:keyref>
</xs:element>
</xs:schema>
"""
let valid = "<orders><product code=\"A\"/><product code=\"B\"/><line product=\"A\"/></orders>"
let dangling = "<orders><product code=\"A\"/><line product=\"Z\"/></orders>"
#expect(try validate(xsd, valid).isEmpty)
#expect(try !validate(xsd, dangling).isEmpty)
}
}