Merge epic/67-xsd-identity: unique, key, keyref identity constraints
This commit is contained in:
commit
f2a1e44e5e
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