fix(schema): streaming validator applies default/fixed value constraints
The streaming XSD validator ignored an element's default/fixed value constraint
in simple-content validation, so an empty element with a value constraint was
validated against the empty string (a false positive), and a present fixed
value was compared as a raw string rather than in the type's value space.
Mirror the tree path's validateChild for the simple-typed element:
- an empty element takes its constraint value via a shared effectiveSimpleText
helper both paths use; the constraint comes from the matched particle (keyed
by namespaced identity), not the flat by-local-name elementConstraints map,
so same-local-name elements in different types do not collide.
- a present fixed value is compared in the type's value space and against the
matched particle's fixed value (elementFixedErrors with valueType and
particleFixed), not a raw string compare ("05" equals int fixed "5").
The particle constraint is looked up from the parent's content model at
element-close time. Streaming now agrees with the tree-path oracle on value
constraints, verified by an adversarial differential (13 configurations).
No XSTS bucket changes; gate held at 0/37/0/26.
This commit is contained in:
parent
f9b56077cc
commit
aeff7869f9
@ -28,6 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- The streaming XSD validator now applies an element's `default`/`fixed` value
|
||||
constraint to its simple content (#200): an empty element takes its constraint
|
||||
value, and that value (not the empty string) is validated against the type
|
||||
(cvc-elt.5.1.2 / cvc-elt.5.2.2.2). The constraint comes from the matched particle
|
||||
(keyed by namespaced identity), not the flat by-local-name map, so two same-local-
|
||||
name elements in different types do not collide. Both paths share one
|
||||
`effectiveSimpleText(particleConstraint:)` helper, so an empty element with a valid
|
||||
default is no longer falsely rejected and streaming agrees with the tree path.
|
||||
|
||||
- The streaming XSD validator now agrees with the tree validator on `xsi:type` and
|
||||
type resolution (#186). Type resolution is keyed by namespaced identity
|
||||
(`{ns}local`) through in-scope prefix bindings accumulated down the element stack:
|
||||
|
||||
@ -104,6 +104,18 @@ public extension PureXML.Schema {
|
||||
return []
|
||||
}
|
||||
|
||||
/// The text an element's simple content is validated against: its character
|
||||
/// content, or, when the element is empty, its `default`/`fixed` value (an
|
||||
/// empty element takes that value, and the value, not the empty string, must
|
||||
/// satisfy the type). The matched particle's constraint takes precedence over
|
||||
/// the by-local-name `elementConstraints` map, exactly as the tree path's
|
||||
/// `validateChild` resolves it.
|
||||
func effectiveSimpleText(for element: PureXML.Model.Element, particleConstraint: ValueConstraint? = nil) -> String {
|
||||
let text = Self.rawTextContent(element)
|
||||
let constraint = particleConstraint ?? elementConstraints[element.name.localName]
|
||||
return text.isEmpty ? (constraint?.value ?? text) : text
|
||||
}
|
||||
|
||||
/// The error from a `fixed` element value constraint: the element's text
|
||||
/// must equal the fixed value in the element type's value space when known.
|
||||
func elementFixedErrors(
|
||||
@ -314,9 +326,7 @@ public extension PureXML.Schema {
|
||||
// (not the empty string) is what must be valid against the type,
|
||||
// including any xsi:type override already resolved into `simple`.
|
||||
let text = Self.rawTextContent(child)
|
||||
let constraint = particleConstraint ?? elementConstraints[child.name.localName]
|
||||
let effective = text.isEmpty ? (constraint?.value ?? text) : text
|
||||
if let error = simple.validate(effective) {
|
||||
if let error = simple.validate(effectiveSimpleText(for: child, particleConstraint: particleConstraint)) {
|
||||
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
|
||||
}
|
||||
recordIDs(simple, value: text, at: path)
|
||||
|
||||
@ -75,6 +75,29 @@ extension PureXML.Schema.ComplexValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-particle `default`/`fixed` value constraint of each named element in a
|
||||
/// content model, keyed by namespaced identity. The streaming validator uses this
|
||||
/// to apply a child's particle constraint (not the by-local-name `elementConstraints`
|
||||
/// map, which collides across same-local-name elements in different types).
|
||||
static func elementValueConstraints(in term: PureXML.Schema.Term) -> [String: PureXML.Schema.ValueConstraint] {
|
||||
var result: [String: PureXML.Schema.ValueConstraint] = [:]
|
||||
collectValueConstraints(term, into: &result)
|
||||
return result
|
||||
}
|
||||
|
||||
fileprivate static func collectValueConstraints(_ term: XSDTerm, into result: inout [String: PureXML.Schema.ValueConstraint]) {
|
||||
switch term {
|
||||
case let .element(name, _, _, valueConstraint, _, _):
|
||||
if let valueConstraint { result[key(name)] = valueConstraint }
|
||||
case let .group(group):
|
||||
for member in group.particles {
|
||||
collectValueConstraints(member.term, into: &result)
|
||||
}
|
||||
case .wildcard:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
static func key(_ name: PureXML.Model.QualifiedName) -> String {
|
||||
"{\(name.namespaceURI ?? "")}\(name.localName)"
|
||||
}
|
||||
|
||||
@ -5,10 +5,14 @@ public extension PureXML.Schema {
|
||||
struct ResolvedElement: PureXML.Validation.Validatable {
|
||||
public let element: PureXML.Model.Element
|
||||
public let type: ElementType
|
||||
/// The matched particle's `default`/`fixed` value constraint, when the element
|
||||
/// is a content-model child, so an empty element validates that value.
|
||||
public let valueConstraint: ValueConstraint?
|
||||
|
||||
public init(element: PureXML.Model.Element, type: ElementType) {
|
||||
public init(element: PureXML.Model.Element, type: ElementType, valueConstraint: ValueConstraint? = nil) {
|
||||
self.element = element
|
||||
self.type = type
|
||||
self.valueConstraint = valueConstraint
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,7 +27,25 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
/// rather than a bare method call.
|
||||
static var shallowValidity: PureXML.Validation.Validation<PureXML.Schema.ResolvedElement, PureXML.Schema.ComplexValidator> {
|
||||
.init(description: "Each streamed element is valid against its declared XSD type") { context in
|
||||
context.document.validateShallow(context.subject.element, as: context.subject.type, at: context.codingPath)
|
||||
context.document.validateShallow(
|
||||
context.subject.element,
|
||||
as: context.subject.type,
|
||||
valueConstraint: context.subject.valueConstraint,
|
||||
at: context.codingPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The matched particle's `default`/`fixed` value constraint for a child of
|
||||
/// `parent`'s content model, keyed by namespaced identity (so same-local-name
|
||||
/// elements in different types do not collide as in the flat `elementConstraints`).
|
||||
func childValueConstraint(of parent: PureXML.Schema.ElementType, child name: PureXML.Model.QualifiedName) -> PureXML.Schema.ValueConstraint? {
|
||||
guard case let .complex(complex) = parent else { return nil }
|
||||
switch complex.content {
|
||||
case let .elementOnly(particle), let .mixed(particle):
|
||||
return Self.elementValueConstraints(in: particle.term)[Self.key(name)]
|
||||
case .empty, .simpleContent:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,12 +157,13 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
func validateShallow(
|
||||
_ element: PureXML.Model.Element,
|
||||
as type: PureXML.Schema.ElementType,
|
||||
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
|
||||
at path: [PureXML.Validation.PathKey] = [],
|
||||
) -> [PureXML.Validation.ValidationError] {
|
||||
var errors: [XSDFailure] = []
|
||||
switch type {
|
||||
case let .simple(simple):
|
||||
validateSimpleElement(element, simple, at: path, into: &errors)
|
||||
validateSimpleElement(element, simple, valueConstraint: valueConstraint, at: path, into: &errors)
|
||||
case let .complex(complex):
|
||||
validateAttributes(element, complex, at: path, into: &errors)
|
||||
if let nilErrors = nilErrors(element, at: path) { return errors + nilErrors }
|
||||
@ -155,13 +178,19 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
case let .circular(name):
|
||||
return [XSDFailure(reason: "circular type reference '\(name)'", at: path)]
|
||||
case let .resolved(resolved):
|
||||
return validateShallow(element, as: resolved, at: path)
|
||||
return validateShallow(element, as: resolved, valueConstraint: valueConstraint, at: path)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private func validateSimpleElement(_ element: PureXML.Model.Element, _ simple: PureXML.Schema.SimpleType, at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
private func validateSimpleElement(
|
||||
_ element: PureXML.Model.Element,
|
||||
_ simple: PureXML.Schema.SimpleType,
|
||||
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
|
||||
at path: XSDPath,
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
if let nilErrors = nilErrors(element, at: path) {
|
||||
errors += nilErrors
|
||||
return
|
||||
@ -169,12 +198,18 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
if !element.children.compactMap(\.element).isEmpty {
|
||||
errors.append(XSDFailure(reason: "'\(element.name.localName)' must not have children", at: path))
|
||||
}
|
||||
// An empty element takes its `default`/`fixed` value; that value (not the
|
||||
// empty string) is what must be valid against the type, matching the tree
|
||||
// path's validateChild.
|
||||
let text = Self.rawTextContent(element)
|
||||
if let error = simple.validate(text) {
|
||||
if let error = simple.validate(effectiveSimpleText(for: element, particleConstraint: valueConstraint)) {
|
||||
errors.append(XSDFailure(reason: "'\(element.name.localName)': \(error)", at: path))
|
||||
}
|
||||
recordIDs(simple, value: text, at: path)
|
||||
errors += elementFixedErrors(element, at: path)
|
||||
// Compare a present fixed value in the type's value space (e.g. "05" == "5"
|
||||
// for xs:int) and against the matched particle's fixed value, as the tree
|
||||
// path's validateChild does, not a raw string compare.
|
||||
errors += elementFixedErrors(element, valueType: simple, particleFixed: valueConstraint?.fixedValue, at: path)
|
||||
}
|
||||
|
||||
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
|
||||
@ -93,8 +93,13 @@ public extension PureXML.Validation {
|
||||
guard let frame = stack.popLast() else { return }
|
||||
if let effective = frame.effective {
|
||||
// Apply the streaming content check as a composable Validation value
|
||||
// (the OpenAPIKit idiom), not a bare method call.
|
||||
let subject = PureXML.Schema.ResolvedElement(element: frame.synthesized(), type: effective)
|
||||
// (the OpenAPIKit idiom), not a bare method call. The parent (now the
|
||||
// stack top) supplies the matched particle's value constraint, so an
|
||||
// empty child validates its `default`/`fixed` value as the tree does.
|
||||
let valueConstraint = stack.last?.effective.flatMap {
|
||||
validator.childValueConstraint(of: $0, child: frame.name)
|
||||
}
|
||||
let subject = PureXML.Schema.ResolvedElement(element: frame.synthesized(), type: effective, valueConstraint: valueConstraint)
|
||||
collected += PureXML.Schema.ComplexValidator.shallowValidity.apply(to: subject, at: frame.path, in: validator)
|
||||
}
|
||||
}
|
||||
|
||||
102
Tests/SchemaStreamingValueConstraintTests.swift
Normal file
102
Tests/SchemaStreamingValueConstraintTests.swift
Normal file
@ -0,0 +1,102 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// An empty element with a `default`/`fixed` value constraint takes that value as
|
||||
/// its content, and that value (not the empty string) must satisfy the element's
|
||||
/// simple type (cvc-elt.5.1.2 / cvc-elt.5.2.2.2). The streaming validator must apply
|
||||
/// the constraint the same way the tree path does (#200), so an empty element with a
|
||||
/// valid default is not falsely rejected. Each case asserts streaming agrees with
|
||||
/// the tree-path oracle.
|
||||
@Suite("XSD streaming default/fixed value constraints (#200)")
|
||||
struct SchemaStreamingValueConstraintTests {
|
||||
private func schema(_ constraint: String) -> String {
|
||||
"""
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
|
||||
<xs:complexType name="T"><xs:sequence>
|
||||
<xs:element name="a" type="xs:int" \(constraint)/>
|
||||
</xs:sequence></xs:complexType>
|
||||
<xs:element name="r" type="t:T"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
}
|
||||
|
||||
private func agreement(_ schemaSource: String, _ instance: String) throws -> [String] {
|
||||
let document = try PureXML.Schema.Document(schemaSource)
|
||||
let tree = try document.validate(instance).map(\.reason).sorted()
|
||||
let streamed = try document.validate(streaming: instance).map(\.reason).sorted()
|
||||
#expect(tree == streamed, "streaming disagreed with tree:\n tree: \(tree)\n stream: \(streamed)")
|
||||
return streamed
|
||||
}
|
||||
|
||||
@Test("an empty element with a valid default is accepted on both paths")
|
||||
func test_emptyTakesDefault() throws {
|
||||
let instance = "<t:r xmlns:t=\"urn:t\"><a></a></t:r>"
|
||||
#expect(try agreement(schema("default=\"5\""), instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("an empty element with a valid fixed value is accepted on both paths")
|
||||
func test_emptyTakesFixed() throws {
|
||||
let instance = "<t:r xmlns:t=\"urn:t\"><a></a></t:r>"
|
||||
#expect(try agreement(schema("fixed=\"5\""), instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a present non-empty value is still validated against the type on both paths")
|
||||
func test_presentValueStillValidated() throws {
|
||||
let instance = "<t:r xmlns:t=\"urn:t\"><a>notanint</a></t:r>"
|
||||
#expect(try !agreement(schema("default=\"5\""), instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("same-local-name elements with different defaults use the matched particle, not the flat map")
|
||||
func test_collidingLocalNameConstraints() throws {
|
||||
// Two complex types both declare a local element 'v' with a different default;
|
||||
// B is declared after A, so the by-local-name elementConstraints map keeps B's
|
||||
// (type-invalid for A). Streaming must use A's matched-particle default "5" for
|
||||
// an empty <v/> under root (type A), not the colliding "hello".
|
||||
let collisionSchema = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
|
||||
<xs:element name="root" type="t:A"/>
|
||||
<xs:complexType name="A"><xs:sequence>
|
||||
<xs:element name="v" type="xs:int" default="5"/>
|
||||
</xs:sequence></xs:complexType>
|
||||
<xs:element name="other" type="t:B"/>
|
||||
<xs:complexType name="B"><xs:sequence>
|
||||
<xs:element name="v" type="xs:string" default="hello"/>
|
||||
</xs:sequence></xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
let instance = "<t:root xmlns:t=\"urn:t\"><v/></t:root>"
|
||||
#expect(try agreement(collisionSchema, instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a present fixed value is compared in the type's value space, not raw string")
|
||||
func test_fixedValueSpaceComparison() throws {
|
||||
// "05" equals fixed "5" in the xs:int value space; both paths accept.
|
||||
let instance = "<t:r xmlns:t=\"urn:t\"><a>05</a></t:r>"
|
||||
#expect(try agreement(schema("fixed=\"5\""), instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a present fixed value that differs in value space is rejected on both paths")
|
||||
func test_fixedValueMismatchRejected() throws {
|
||||
let instance = "<t:r xmlns:t=\"urn:t\"><a>6</a></t:r>"
|
||||
#expect(try !agreement(schema("fixed=\"5\""), instance).isEmpty)
|
||||
}
|
||||
|
||||
@Test("a present fixed value under a same-local-name collision uses the matched particle")
|
||||
func test_fixedCollisionPresentContent() throws {
|
||||
let collisionSchema = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
|
||||
<xs:element name="root" type="t:A"/>
|
||||
<xs:complexType name="A"><xs:sequence>
|
||||
<xs:element name="v" type="xs:int" fixed="5"/>
|
||||
</xs:sequence></xs:complexType>
|
||||
<xs:element name="other" type="t:B"/>
|
||||
<xs:complexType name="B"><xs:sequence>
|
||||
<xs:element name="v" type="xs:string" fixed="hello"/>
|
||||
</xs:sequence></xs:complexType>
|
||||
</xs:schema>
|
||||
"""
|
||||
// <v>5</v> under root (type A) matches A's fixed "5", not B's colliding "hello".
|
||||
let instance = "<t:root xmlns:t=\"urn:t\"><v>5</v></t:root>"
|
||||
#expect(try agreement(collisionSchema, instance).isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user