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
This commit is contained in:
parent
628b65df93
commit
fe19b0b1ea
31
Sources/Schema/IdentityConstraint.swift
Normal file
31
Sources/Schema/IdentityConstraint.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Sources/Schema/IdentityValidator.swift
Normal file
111
Sources/Schema/IdentityValidator.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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] {
|
||||
|
||||
81
Tests/XSDIdentityTests.swift
Normal file
81
Tests/XSDIdentityTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user