Consolidates the in-progress #146 work; valid-instances-rejected reaches 4 on the 2006-11-06 XSTS archive (buckets now 1/262/4/147). Regex: match Unicode scalars rather than grapheme clusters; the empty pattern matches only the empty string; pin the category repertoire to Unicode 3.2, the version the corpus is consistent at, so code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters while later additions (the Tamil digit zero) stay unassigned and match \D. New XSDCategory and a 3.2 assignment table derived from DerivedAge, replacing the old Unicode31 files. Content model: assess each child against the particle the content automaton matched, not a by-name lookup (#180). Two same-named particles keep their own fixed values, and an element matching a skip wildcard is no longer validated against its global declaration. Selection is made reproducible when an ambiguous schema slips past the UPA check. XSTS runner: compile and merge every schemaDocument in a schemaTest, not just the first; this also surfaced one previously-masked invalid-instance acceptance (recorded in the baselines). Also folds in attribute-group anyAttribute inheritance, extension wildcard union, namespace-qualified type tables for multi-document xsi:schemaLocation, per-container elementFormDefault, strict and lax attribute wildcard validation, built-in type resolution, and xs:redefine simpleType overrides.
340 lines
17 KiB
Swift
340 lines
17 KiB
Swift
/// Compiles each distinct XPath string once per validation run, so a
|
|
/// constraint's selector and field queries are not re-parsed at every element
|
|
/// the walk visits. A string that fails to compile caches as nil (no match).
|
|
private final class XPathQueryCache {
|
|
private var queries: [String: PureXML.XPath.Query?] = [:]
|
|
|
|
func query(_ xpath: String) -> PureXML.XPath.Query? {
|
|
if let cached = queries[xpath] { return cached }
|
|
let compiled = try? PureXML.XPath.Query(xpath)
|
|
queries[xpath] = compiled
|
|
return compiled
|
|
}
|
|
}
|
|
|
|
/// One field of an identity tuple: its lexical value and the simple type it
|
|
/// compares in (from the node's `xsi:type`, if any). Two field values are equal
|
|
/// when they denote the same value: in the value space of their type when both
|
|
/// share a built-in base (so `3.0` equals `3` for `xsd:decimal`), otherwise by
|
|
/// lexical form.
|
|
private struct FieldValue: Equatable {
|
|
let string: String
|
|
let type: PureXML.Schema.SimpleType?
|
|
let namespaceBindings: [String: String]
|
|
|
|
init(string: String, type: PureXML.Schema.SimpleType?, namespaceBindings: [String: String] = [:]) {
|
|
self.string = string
|
|
self.type = type
|
|
self.namespaceBindings = namespaceBindings
|
|
}
|
|
|
|
static func == (lhs: FieldValue, rhs: FieldValue) -> Bool {
|
|
if let left = lhs.qnameValue, let right = rhs.qnameValue, left.uri == right.uri, left.local == right.local {
|
|
return true
|
|
}
|
|
if let lhsType = lhs.type, let rhsType = rhs.type {
|
|
if lhsType.base == rhsType.base {
|
|
return lhsType.valueMatches(lhs.string, literal: rhs.string)
|
|
}
|
|
return false
|
|
}
|
|
return lhs.string == rhs.string
|
|
}
|
|
|
|
private var qnameValue: (uri: String?, local: String)? {
|
|
let trimmed = string.trimmingXMLWhitespace()
|
|
guard trimmed.contains(":") else { return nil }
|
|
let parts = trimmed.split(separator: ":", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return nil }
|
|
let uri = namespaceBindings[parts[0]] ?? (parts[0].isEmpty ? namespaceBindings[""] : nil)
|
|
return (uri, parts[1])
|
|
}
|
|
}
|
|
|
|
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]]
|
|
/// Simple types for identity-constraint fields keyed by
|
|
/// ``XSDParser/identityFieldKey(selector:field:)``.
|
|
let fieldTypes: [String: SimpleType]
|
|
/// Global element and type declarations for runtime field typing.
|
|
let types: [String: ElementType]
|
|
/// Selector and field XPaths compiled once per run, not per element.
|
|
private let cache = XPathQueryCache()
|
|
|
|
init(
|
|
constraints: [String: [IdentityConstraint]],
|
|
fieldTypes: [String: SimpleType] = [:],
|
|
types: [String: ElementType] = [:],
|
|
) {
|
|
self.constraints = constraints
|
|
self.fieldTypes = fieldTypes
|
|
self.types = types
|
|
}
|
|
|
|
func validate(
|
|
_ root: PureXML.Model.TreeNode,
|
|
at path: [PureXML.Validation.PathKey] = [],
|
|
) -> [PureXML.Validation.ValidationError] {
|
|
guard !constraints.isEmpty else { return [] }
|
|
// A selector or field XPath that does not compile is a schema-author
|
|
// error, reported once up front rather than silently disabling the
|
|
// constraint (the broken query still evaluates as no-match below).
|
|
var issues: [PureXML.Validation.ValidationError] = compileErrors(at: path)
|
|
walk(root, at: path, scopes: [], into: &issues)
|
|
return issues
|
|
}
|
|
|
|
/// One located error per constraint XPath that fails to compile, in a
|
|
/// deterministic order. The failed compilations are cached, so the walk
|
|
/// afterwards treats those constraints as matching nothing.
|
|
private func compileErrors(at path: [PureXML.Validation.PathKey]) -> [PureXML.Validation.ValidationError] {
|
|
var errors: [PureXML.Validation.ValidationError] = []
|
|
for key in constraints.keys.sorted() {
|
|
for constraint in constraints[key] ?? [] {
|
|
if cache.query(constraint.selector) == nil {
|
|
errors.append(.init(reason: "identity constraint '\(constraint.name)': invalid selector XPath '\(constraint.selector)'", at: path))
|
|
}
|
|
for field in constraint.fields where cache.query(field) == nil {
|
|
errors.append(.init(reason: "identity constraint '\(constraint.name)': invalid field XPath '\(field)'", at: path))
|
|
}
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
private func walk(
|
|
_ node: PureXML.Model.TreeNode,
|
|
at path: [PureXML.Validation.PathKey],
|
|
scopes: [[String: [[FieldValue?]]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
var frame: [String: [[FieldValue?]]] = [:]
|
|
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, path: path, frame: &frame, into: &issues)
|
|
}
|
|
let visible = scopes + [frame]
|
|
for constraint in declared {
|
|
if case .keyref = constraint.kind {
|
|
checkKeyref(constraint, at: node, path: path, scopes: visible, into: &issues)
|
|
}
|
|
}
|
|
let elementChildren = node.children.filter { $0.kind == .element }
|
|
for (child, step) in zip(elementChildren, childSteps(elementChildren)) {
|
|
walk(child, at: path + [step], scopes: visible, into: &issues)
|
|
}
|
|
}
|
|
|
|
/// The coding-path step for each element child: its name, with a sibling
|
|
/// index only when more than one child shares that name. Mirrors the
|
|
/// complex-type validator's path construction so identity-constraint
|
|
/// errors and content errors locate consistently.
|
|
private func childSteps(_ children: [PureXML.Model.TreeNode]) -> [PureXML.Validation.PathKey] {
|
|
PureXML.Validation.PathKey.steps(forChildNames: children.map { $0.name?.description ?? "" })
|
|
}
|
|
|
|
/// The coding-path steps from the constraint-bearing `container` down to a
|
|
/// selected `target`, so an identity error locates the actual offending
|
|
/// element rather than the element that merely declares the constraint.
|
|
/// Returns an empty path if `target` is not a descendant of `container`.
|
|
private func steps(from container: PureXML.Model.TreeNode, to target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] {
|
|
var chain: [PureXML.Model.TreeNode] = []
|
|
var current = target
|
|
while current !== container {
|
|
guard let parent = current.parent else { return [] }
|
|
chain.append(current)
|
|
current = parent
|
|
}
|
|
var steps: [PureXML.Validation.PathKey] = []
|
|
var parent = container
|
|
for node in chain.reversed() {
|
|
let name = node.name?.description ?? ""
|
|
let siblings = parent.children.filter { $0.kind == .element && ($0.name?.description ?? "") == name }
|
|
if siblings.count > 1, let index = siblings.firstIndex(where: { $0 === node }) {
|
|
steps.append(.element(name, index: index + 1))
|
|
} else {
|
|
steps.append(.element(name))
|
|
}
|
|
parent = node
|
|
}
|
|
return steps
|
|
}
|
|
|
|
/// The coding-path step from a `target` down to the single field a
|
|
/// constraint names, so an error locates the offending value (`@id`, a
|
|
/// child element) rather than just the target. Empty for a multi-field
|
|
/// constraint or a complex field path, in which case the error stays on the
|
|
/// target.
|
|
private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] {
|
|
guard fields.count == 1 else { return [] }
|
|
let field = fields[0]
|
|
if field.hasPrefix("@"), !field.dropFirst().contains(where: { $0 == "/" || $0 == "[" }) {
|
|
return [.attribute(String(field.dropFirst()))]
|
|
}
|
|
let isSimpleName = !field.contains(where: { "/@[(".contains($0) })
|
|
if isSimpleName, let child = select(field, at: target).first, child.kind == .element {
|
|
return steps(from: target, to: child)
|
|
}
|
|
return []
|
|
}
|
|
|
|
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,
|
|
path: [PureXML.Validation.PathKey],
|
|
frame: inout [String: [[FieldValue?]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
guard isNonRef(constraint) else { return }
|
|
var tuples: [[FieldValue?]] = []
|
|
var seen: [[FieldValue?]] = []
|
|
for target in select(constraint.selector, at: node) {
|
|
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
|
let targetPath = path + steps(from: node, to: target)
|
|
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
|
|
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
continue
|
|
}
|
|
if tuple.contains(where: { $0 == nil }) { continue }
|
|
if seen.contains(where: { $0 == tuple }) {
|
|
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
} else {
|
|
seen.append(tuple)
|
|
}
|
|
tuples.append(tuple)
|
|
}
|
|
frame[constraint.name] = tuples
|
|
}
|
|
|
|
private func checkKeyref(
|
|
_ constraint: IdentityConstraint,
|
|
at node: PureXML.Model.TreeNode,
|
|
path: [PureXML.Validation.PathKey],
|
|
scopes: [[String: [[FieldValue?]]]],
|
|
into issues: inout [PureXML.Validation.ValidationError],
|
|
) {
|
|
guard case let .keyref(refer) = constraint.kind else { return }
|
|
let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
|
|
for target in select(constraint.selector, at: node) {
|
|
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
|
if tuple.contains(where: { $0 == nil }) { continue }
|
|
if !keyTuples.contains(where: { $0 == tuple }) {
|
|
let targetPath = path + steps(from: node, to: target)
|
|
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: XPath helpers
|
|
|
|
private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
|
guard let query = cache.query(xpath) else { return [] }
|
|
return query.nodes(over: node, namespaces: namespaceBindings(at: node))
|
|
}
|
|
|
|
private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode, constraint: IdentityConstraint) -> [FieldValue?] {
|
|
let namespaces = namespaceBindings(at: node)
|
|
return fields.map { field in
|
|
guard let query = cache.query(field),
|
|
let value = try? query.value(at: node, namespaces: namespaces)
|
|
else {
|
|
return nil
|
|
}
|
|
if let nodes = value.nodes, nodes.isEmpty { return nil }
|
|
let type = resolveFieldType(field, constraint: constraint, at: node)
|
|
?? Self.effectiveType(of: value)
|
|
return FieldValue(string: value.string, type: type, namespaceBindings: namespaces)
|
|
}
|
|
}
|
|
|
|
/// Prefix bindings in scope at `node`, gathered from ancestor elements.
|
|
private func namespaceBindings(at node: PureXML.Model.TreeNode) -> [String: String] {
|
|
var chain: [PureXML.Model.TreeNode] = []
|
|
var current: PureXML.Model.TreeNode? = node
|
|
while let element = current, element.kind == .element {
|
|
chain.append(element)
|
|
current = element.parent
|
|
}
|
|
var bindings: [String: String] = [:]
|
|
for element in chain.reversed() {
|
|
for attribute in element.attributes {
|
|
if attribute.name.prefix == "xmlns" {
|
|
bindings[attribute.name.localName] = attribute.value
|
|
} else if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
|
|
bindings[""] = attribute.value
|
|
}
|
|
}
|
|
}
|
|
return bindings
|
|
}
|
|
|
|
/// The simple type a field value compares in: the built-in named by the
|
|
/// selected node's `xsi:type`, when present. Identity-constraint values
|
|
/// compare in their value space, so two `xsi:type="xsd:decimal"` fields
|
|
/// `3.0` and `3` are the same value and violate a `unique`/`key`. With no
|
|
/// `xsi:type` the comparison falls back to the lexical form (the type
|
|
/// declared in the schema is not threaded into identity validation).
|
|
private func resolveFieldType(_ field: String, constraint: IdentityConstraint, at target: PureXML.Model.TreeNode) -> SimpleType? {
|
|
if field == ".", let local = target.name?.localName {
|
|
if let type = fieldTypes[PureXML.Schema.XSDParser.identityFieldKey(constraint: constraint, field: field, targetLocal: local)] {
|
|
return type
|
|
}
|
|
guard let name = target.name else { return nil }
|
|
if let declaration = types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] {
|
|
return simpleType(of: declaration)
|
|
}
|
|
if let declaration = types[PureXML.Schema.XSDParser.elementKey(name.localName)] {
|
|
return simpleType(of: declaration)
|
|
}
|
|
}
|
|
return fieldTypes[PureXML.Schema.XSDParser.identityFieldKey(constraint: constraint, field: field)]
|
|
}
|
|
|
|
private func simpleType(of declaration: ElementType) -> SimpleType? {
|
|
switch declaration {
|
|
case let .simple(simple): simple
|
|
case let .complex(complex):
|
|
if case let .simpleContent(simple) = complex.content { simple } else { nil }
|
|
case .typeReference: nil
|
|
}
|
|
}
|
|
|
|
private static func effectiveType(of value: PureXML.XPath.Value) -> SimpleType? {
|
|
guard let nodes = value.nodes, case let .tree(treeNode)? = nodes.first,
|
|
treeNode.kind == .element, let local = xsiTypeLocalName(of: treeNode),
|
|
let builtin = BuiltinType(rawValue: local)
|
|
else { return nil }
|
|
return SimpleType(base: builtin)
|
|
}
|
|
|
|
/// The local name of a tree node's `xsi:type` attribute value, recognized by
|
|
/// the schema-instance namespace or the conventional `xsi` prefix, or nil.
|
|
private static func xsiTypeLocalName(of node: PureXML.Model.TreeNode) -> String? {
|
|
let schemaInstance = "http://www.w3.org/2001/XMLSchema-instance"
|
|
guard let attribute = node.attributes.first(where: {
|
|
$0.name.localName == "type" && ($0.name.namespaceURI == schemaInstance || $0.name.prefix == "xsi")
|
|
}) else { return nil }
|
|
return attribute.value.split(separator: ":").last.map(String.init) ?? attribute.value
|
|
}
|
|
|
|
private func label(_ constraint: IdentityConstraint) -> String {
|
|
constraint.kind == .key ? "key" : "unique"
|
|
}
|
|
}
|
|
}
|