fix(xsd): locate identity-constraint errors at their element
The IdentityValidator emitted every key/keyref/unique error with an empty coding path (at: []), so each rendered "at root of document" regardless of which element violated the constraint. Thread a [PathKey] through the walk, the way the content-model validator already does, so the failures carry the constraining element's path. This restores the validation framework's path-carrying contract for the one rule that was bypassing it.
This commit is contained in:
parent
74acad4628
commit
9730903803
@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- XSD identity-constraint errors (`key`, `keyref`, `unique`) now carry a coding path locating the constraining element, instead of always rendering "at root of document". The `IdentityValidator` walk threads a `[PathKey]` the same way the content-model validator does, so a duplicate-key or dangling-keyref failure reports "... at path: list" rather than an empty path. This brings the identity-constraint rule into line with the validation framework's path-carrying contract, which every other rule already honored.
|
||||
- A `<template>` element's flow content is now kept nested inside it (`<template><div>x</div></template>`) instead of being split out into the body (#83). `template` is built as body flow content rather than routed to the head.
|
||||
|
||||
### Added
|
||||
|
||||
@ -88,7 +88,7 @@ flowchart TB
|
||||
|
||||
PureXML is a working, dependency-free XML library today: parse, emit, validate,
|
||||
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
|
||||
currently runs **765 tests in 117 suites** (`swift test`).
|
||||
currently runs **766 tests in 117 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
@ -9,15 +9,19 @@ extension PureXML.Schema {
|
||||
/// The constraints declared for each element local name.
|
||||
let constraints: [String: [IdentityConstraint]]
|
||||
|
||||
func validate(_ root: PureXML.Model.TreeNode) -> [PureXML.Validation.ValidationError] {
|
||||
func validate(
|
||||
_ root: PureXML.Model.TreeNode,
|
||||
at path: [PureXML.Validation.PathKey] = [],
|
||||
) -> [PureXML.Validation.ValidationError] {
|
||||
guard !constraints.isEmpty else { return [] }
|
||||
var issues: [PureXML.Validation.ValidationError] = []
|
||||
walk(root, scopes: [], into: &issues)
|
||||
walk(root, at: path, scopes: [], into: &issues)
|
||||
return issues
|
||||
}
|
||||
|
||||
private func walk(
|
||||
_ node: PureXML.Model.TreeNode,
|
||||
at path: [PureXML.Validation.PathKey],
|
||||
scopes: [[String: [[String?]]]],
|
||||
into issues: inout [PureXML.Validation.ValidationError],
|
||||
) {
|
||||
@ -26,16 +30,35 @@ extension PureXML.Schema {
|
||||
// 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)
|
||||
collect(constraint, at: node, path: path, 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)
|
||||
if case .keyref = constraint.kind {
|
||||
checkKeyref(constraint, at: node, path: path, scopes: visible, into: &issues)
|
||||
}
|
||||
}
|
||||
for child in node.children where child.kind == .element {
|
||||
walk(child, 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] {
|
||||
var totals: [String: Int] = [:]
|
||||
for child in children {
|
||||
totals[child.name?.description ?? "", default: 0] += 1
|
||||
}
|
||||
var seen: [String: Int] = [:]
|
||||
return children.map { child in
|
||||
let name = child.name?.description ?? ""
|
||||
let index = (seen[name] ?? 0) + 1
|
||||
seen[name] = index
|
||||
return (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,6 +70,7 @@ extension PureXML.Schema {
|
||||
private func collect(
|
||||
_ constraint: IdentityConstraint,
|
||||
at node: PureXML.Model.TreeNode,
|
||||
path: [PureXML.Validation.PathKey],
|
||||
frame: inout [String: [[String?]]],
|
||||
into issues: inout [PureXML.Validation.ValidationError],
|
||||
) {
|
||||
@ -56,12 +80,12 @@ extension PureXML.Schema {
|
||||
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(reason: "key '\(constraint.name)': a field is missing", at: []))
|
||||
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: path))
|
||||
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: []))
|
||||
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: path))
|
||||
} else {
|
||||
seen.append(tuple)
|
||||
}
|
||||
@ -72,17 +96,18 @@ extension PureXML.Schema {
|
||||
|
||||
private func checkKeyref(
|
||||
_ constraint: IdentityConstraint,
|
||||
refer: String,
|
||||
at node: PureXML.Model.TreeNode,
|
||||
path: [PureXML.Validation.PathKey],
|
||||
scopes: [[String: [[String?]]]],
|
||||
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)
|
||||
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: []))
|
||||
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,8 +79,9 @@ public extension PureXML.Validation {
|
||||
.init(
|
||||
description: "XSD identity constraints hold",
|
||||
check: { context in
|
||||
PureXML.Schema.IdentityValidator(constraints: context.document.constraints)
|
||||
.validate(PureXML.Model.TreeNode(context.subject))
|
||||
guard case let .element(root) = context.subject else { return [] }
|
||||
return PureXML.Schema.IdentityValidator(constraints: context.document.constraints)
|
||||
.validate(PureXML.Model.TreeNode(context.subject), at: [.element(root.name.description)])
|
||||
},
|
||||
when: { $0.codingPath.isEmpty },
|
||||
)
|
||||
|
||||
@ -78,4 +78,25 @@ struct XSDIdentityTests {
|
||||
#expect(try validate(xsd, valid).isEmpty)
|
||||
#expect(try !validate(xsd, dangling).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Identity-constraint errors are located at the constraining element")
|
||||
func test_identityErrorsCarryPath() 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>
|
||||
"""
|
||||
let errors = try validate(xsd, "<list><item id=\"1\"/><item id=\"1\"/></list>")
|
||||
let failure = try #require(errors.first)
|
||||
#expect(failure.codingPath.map(\.stringValue) == ["list"])
|
||||
#expect(String(describing: failure).hasSuffix("at path: list"))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user