fix(schema): resolve identity-constraint XPath prefixes against the schema (#147)
Identity-constraint selector and field XPath prefixes were resolved against the instance document's namespace bindings, so a constraint declared with xmlns:v="..." and a selector like .//v:vehicle matched nothing when the instance used a default namespace instead of the v prefix. The selector then selected no nodes, no key tuples were collected, and duplicate-key and keyref violations went undetected. Prefixes now resolve against the namespace bindings in scope where the constraint is declared in the schema (XSD Part 1, Identity-constraint Definition), captured at parse time onto IdentityConstraint. A QName field value still compares using the instance's bindings, so QName keys are unaffected. XSTS invalid-instances-accepted 143 to 133: the idc004/5/6 namespaced key/keyref cluster and namespace-resolution cases (idZ010 and kin), no other bucket moved.
This commit is contained in:
parent
9769f022dd
commit
39a91c258e
@ -22,7 +22,7 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s
|
||||
|
||||
### Fixed
|
||||
|
||||
- XSTS invalid-instances-accepted (#147): identity-constraint fields now enforce cardinality (a `field` must select at most one node; cvc-identity-constraint.3), so an instance whose `unique`/`key`/`keyref` field matches several nodes is rejected rather than accepted. Caught four cases (idF005, idG005, and kin): invalid-instances-accepted 147 → 143, no other bucket moved.
|
||||
- XSTS invalid-instances-accepted (#147): identity constraints reject more invalid instances. Fields now enforce cardinality (a `field` must select at most one node; cvc-identity-constraint.3), so an instance whose `unique`/`key`/`keyref` field matches several nodes is rejected. Selector and field XPath prefixes now resolve against the schema's namespace context (where the constraint is declared) rather than the instance document, so a constraint like `.//v:vehicle` works whatever prefix the instance uses; a QName field value still compares using the instance's bindings. Together these caught fourteen cases (idF005, idG005, the idc004/5/6 cluster, idZ010, and kin): invalid-instances-accepted 147 → 133, no other bucket moved.
|
||||
|
||||
- XSTS valid-instances-rejected (#146): **173 → 3** on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/143). Regex patterns match Unicode scalars (not Swift grapheme clusters), fixing `\p{Is…}` block tests and combining-mark cases; empty patterns match only the empty string. The regex repertoire is pinned to Unicode 3.2, the version the corpus is consistent at: code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters, while later additions (the Tamil digit zero) stay unassigned and match `\D` (`XSDCategory`, `Unicode32Assigned`). Per-child content assessment is driven by the content-model particle the automaton matched, not a by-name lookup (#180), so two same-named particles keep their own `fixed` values and an element matching a `skip` wildcard is not validated against its global declaration. Schema documents that form an import chain are compiled as a union rather than merged as separately compiled documents, so cross-document substitution groups resolve (#161, `Document.init(composing:)`). Plus attribute-group `anyAttribute` inheritance, extension wildcard union, namespace-qualified type tables for multi-document `xsi:schemaLocation`, per-container `elementFormDefault`, strict/lax attribute wildcard validation, built-in type resolution, and `xs:redefine` simpleType overrides.
|
||||
|
||||
|
||||
@ -20,12 +20,24 @@ public extension PureXML.Schema {
|
||||
public var kind: IdentityConstraintKind
|
||||
public var selector: String
|
||||
public var fields: [String]
|
||||
/// Prefix-to-namespace bindings in scope where the constraint is declared
|
||||
/// in the schema. Selector and field XPath prefixes resolve against these
|
||||
/// (XSD Part 1, Identity-constraint Definition), not against the instance
|
||||
/// document, so `.//v:vehicle` works whatever prefix the instance uses.
|
||||
public var namespaceBindings: [String: String]
|
||||
|
||||
public init(name: String, kind: IdentityConstraintKind, selector: String, fields: [String]) {
|
||||
public init(
|
||||
name: String,
|
||||
kind: IdentityConstraintKind,
|
||||
selector: String,
|
||||
fields: [String],
|
||||
namespaceBindings: [String: String] = [:],
|
||||
) {
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.selector = selector
|
||||
self.fields = fields
|
||||
self.namespaceBindings = namespaceBindings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,46 +143,19 @@ extension PureXML.Schema {
|
||||
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] {
|
||||
private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode, namespaces: [String: String]) -> [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 {
|
||||
if isSimpleName, let child = select(field, at: target, namespaces: namespaces).first, child.kind == .element {
|
||||
return steps(from: target, to: child)
|
||||
}
|
||||
return []
|
||||
@ -203,7 +176,7 @@ extension PureXML.Schema {
|
||||
guard isNonRef(constraint) else { return }
|
||||
var tuples: [[FieldValue?]] = []
|
||||
var seen: [[FieldValue?]] = []
|
||||
for target in select(constraint.selector, at: node) {
|
||||
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
|
||||
let targetPath = path + steps(from: node, to: target)
|
||||
if let error = fieldCardinalityError(constraint, at: target, path: targetPath) {
|
||||
issues.append(error)
|
||||
@ -211,12 +184,18 @@ extension PureXML.Schema {
|
||||
}
|
||||
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
||||
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)))
|
||||
issues.append(.init(
|
||||
reason: "key '\(constraint.name)': a field is missing",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
))
|
||||
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)))
|
||||
issues.append(.init(
|
||||
reason: "\(label(constraint)) '\(constraint.name)': duplicate value",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
))
|
||||
} else {
|
||||
seen.append(tuple)
|
||||
}
|
||||
@ -234,7 +213,7 @@ extension PureXML.Schema {
|
||||
) {
|
||||
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) {
|
||||
for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) {
|
||||
let targetPath = path + steps(from: node, to: target)
|
||||
if let error = fieldCardinalityError(constraint, at: target, path: targetPath) {
|
||||
issues.append(error)
|
||||
@ -243,7 +222,10 @@ extension PureXML.Schema {
|
||||
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
|
||||
if tuple.contains(where: { $0 == nil }) { continue }
|
||||
if !keyTuples.contains(where: { $0 == tuple }) {
|
||||
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target)))
|
||||
issues.append(.init(
|
||||
reason: "keyref '\(constraint.name)': no matching key '\(refer)'",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -257,10 +239,10 @@ extension PureXML.Schema {
|
||||
at target: PureXML.Model.TreeNode,
|
||||
path targetPath: [PureXML.Validation.PathKey],
|
||||
) -> PureXML.Validation.ValidationError? {
|
||||
for field in constraint.fields where select(field, at: target).count > 1 {
|
||||
for field in constraint.fields where select(field, at: target, namespaces: constraint.namespaceBindings).count > 1 {
|
||||
return .init(
|
||||
reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must select at most one node",
|
||||
at: targetPath + fieldStep(constraint.fields, at: target),
|
||||
at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@ -268,23 +250,26 @@ extension PureXML.Schema {
|
||||
|
||||
// MARK: XPath helpers
|
||||
|
||||
private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
||||
private func select(_ xpath: String, at node: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.Model.TreeNode] {
|
||||
guard let query = cache.query(xpath) else { return [] }
|
||||
return query.nodes(over: node, namespaces: namespaceBindings(at: node))
|
||||
return query.nodes(over: node, namespaces: namespaces)
|
||||
}
|
||||
|
||||
private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode, constraint: IdentityConstraint) -> [FieldValue?] {
|
||||
let namespaces = namespaceBindings(at: node)
|
||||
// The field XPath's prefixes resolve against the schema (where the
|
||||
// constraint is declared); a QName field VALUE resolves against the
|
||||
// instance, so the FieldValue keeps the instance bindings.
|
||||
let instanceNamespaces = namespaceBindings(at: node)
|
||||
return fields.map { field in
|
||||
guard let query = cache.query(field),
|
||||
let value = try? query.value(at: node, namespaces: namespaces)
|
||||
let value = try? query.value(at: node, namespaces: constraint.namespaceBindings)
|
||||
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)
|
||||
return FieldValue(string: value.string, type: type, namespaceBindings: instanceNamespaces)
|
||||
}
|
||||
}
|
||||
|
||||
@ -363,3 +348,32 @@ extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Schema.IdentityValidator {
|
||||
/// 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`.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,7 +156,13 @@ extension PureXML.Schema.XSDParser {
|
||||
}
|
||||
let selector = PureXML.Schema.XSDNode.firstChild(node, named: "selector").flatMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } ?? ""
|
||||
let fields = PureXML.Schema.XSDNode.children(node, named: "field").compactMap { PureXML.Schema.XSDNode.attribute($0, "xpath") }
|
||||
return PureXML.Schema.IdentityConstraint(name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields)
|
||||
return PureXML.Schema.IdentityConstraint(
|
||||
name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "",
|
||||
kind: kind,
|
||||
selector: selector,
|
||||
fields: fields,
|
||||
namespaceBindings: PureXML.Schema.XSDParser.namespaceBindingsInScope(of: node, defaultBindings: [:]),
|
||||
)
|
||||
}
|
||||
|
||||
static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] {
|
||||
|
||||
@ -57,10 +57,16 @@ struct XSTSSuiteTests {
|
||||
/// one node; cvc-identity-constraint.3) then caught four invalid instances that
|
||||
/// were accepted (idF005, idG005, and kin): invalid accepted 147 → 143, no
|
||||
/// other bucket moved.
|
||||
///
|
||||
/// Resolving identity-constraint selector/field XPath prefixes against the
|
||||
/// schema's namespace context (where the constraint is declared) rather than
|
||||
/// the instance document then caught ten more (the idc004/5/6 cluster and
|
||||
/// namespace-resolution cases): invalid accepted 143 → 133, no other bucket
|
||||
/// moved.
|
||||
private let knownSchemaValidRejected = 1
|
||||
private let knownSchemaInvalidAccepted = 262
|
||||
private let knownInstanceValidRejected = 3
|
||||
private let knownInstanceInvalidAccepted = 143
|
||||
private let knownInstanceInvalidAccepted = 133
|
||||
|
||||
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
|
||||
func test_suite() throws {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user