refactor(xsd): schema-consistency checks through the validation framework
The derivation final check and Particle Valid (Restriction) move from fail-fast throws at schema compile into composable Validation rules (finalRespected, restrictionsAreSubsets) over per-type SchemaTypeFact subjects, so a schema with several problems reports every finding at once. SchemaError gains .inconsistent([String]) carrying all findings (texts unchanged) and drops .finalViolation/.invalidRestriction. checkRedefine, checkAllGroups, and notASchema stay throws: they examine the raw schema source, the compilation analog of well-formedness.
This commit is contained in:
parent
32d42410b9
commit
c8f91e329c
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Schema-consistency checks now run through the validation framework (#117 follow-up): the derivation `final` check and Particle Valid (Restriction) are composable `Validation<SchemaTypeFact, CompiledSchemaFacts>` rules (`finalRespected`, `restrictionsAreSubsets`) applied per named type, so a schema with several problems reports them ALL at once. `SchemaError.finalViolation` and `.invalidRestriction` are replaced by `.inconsistent([String])` carrying every finding with its type's coding path; the finding texts are unchanged. `checkRedefine`, `checkAllGroups`, and `notASchema` stay fail-fast throws because they examine the raw schema source, the compilation analog of well-formedness.
|
||||
- Document-order sorts no longer recompute root paths per comparison (#113): node-set ordering (the evaluator's order-and-dedup, EXSLT's set functions, string-value coercion's first-node lookup) goes through decorate-sort-undecorate helpers (`sortedByDocumentOrder()`, `firstInDocumentOrder()`) that compute each node's order key once, turning O(n log n x depth) sorts into O(n log n). `set:leading`/`set:trailing` also reuse one precomputed pivot key. Behavior is unchanged.
|
||||
- XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path.
|
||||
- The HTML document builder's body is now constructed as a live mutable `TreeNode` tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees.
|
||||
|
||||
@ -99,7 +99,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 **846 tests in 141 suites** (`swift test`).
|
||||
currently runs **850 tests in 142 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
@ -2,8 +2,6 @@ public extension PureXML.Schema {
|
||||
/// An error compiling or applying a schema.
|
||||
enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
case notASchema
|
||||
/// A type derives from a base that declares that derivation method `final`.
|
||||
case finalViolation(type: String, base: String, method: String)
|
||||
/// A type inside `xs:redefine` does not redefine itself: its restriction or
|
||||
/// extension base must name the type it redefines.
|
||||
case redefineIncompatible(type: String)
|
||||
@ -11,22 +9,21 @@ public extension PureXML.Schema {
|
||||
/// an element with `maxOccurs` at most 1, and the group itself may occur at
|
||||
/// most once.
|
||||
case invalidAllGroup(reason: String)
|
||||
/// A complex-type restriction's content model is not a structurally valid
|
||||
/// subset of its base's ("Particle Valid (Restriction)" in XSD 1.0).
|
||||
case invalidRestriction(type: String, base: String, reason: String)
|
||||
/// The compiled schema fails one or more consistency rules (a `final`
|
||||
/// violation, an unfaithful restriction); every finding is carried, so a
|
||||
/// schema with several problems reports them all at once.
|
||||
case inconsistent([String])
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .notASchema:
|
||||
"the document is not an xs:schema"
|
||||
case let .finalViolation(type, base, method):
|
||||
"type '\(type)' derives from '\(base)' by \(method), which '\(base)' declares final"
|
||||
case let .redefineIncompatible(type):
|
||||
"redefined type '\(type)' must derive from itself"
|
||||
case let .invalidAllGroup(reason):
|
||||
"invalid xs:all group: \(reason)"
|
||||
case let .invalidRestriction(type, base, reason):
|
||||
"type '\(type)' is not a valid restriction of '\(base)': \(reason)"
|
||||
case let .inconsistent(findings):
|
||||
findings.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,17 +59,16 @@ public extension PureXML.Schema {
|
||||
typeBlock = compiled.typeBlock
|
||||
typeDerivation = compiled.typeDerivation
|
||||
targetNamespace = compiled.targetNamespace
|
||||
// Particle Valid (Restriction): a complex type derived by restriction
|
||||
// must accept a subset of its base (deterministic order for stable
|
||||
// error reporting).
|
||||
for name in typeDerivation.keys.sorted() {
|
||||
guard let derivation = typeDerivation[name], derivation.method == .restriction,
|
||||
case let .complex(restrictedType)? = types[name],
|
||||
case let .complex(baseType)? = types[derivation.base]
|
||||
else { continue }
|
||||
if let reason = ParticleRestriction.violation(restricted: restrictedType.content, base: baseType.content) {
|
||||
throw SchemaError.invalidRestriction(type: name, base: derivation.base, reason: reason)
|
||||
}
|
||||
// Schema consistency through the validation framework: every named
|
||||
// type is checked by the composable rules (final respected, Particle
|
||||
// Valid (Restriction)), and ALL findings are reported together.
|
||||
let findings = PureXML.Validation.XSDSchema.consistencyErrors(
|
||||
types: compiled.types,
|
||||
typeDerivation: compiled.typeDerivation,
|
||||
typeFinal: compiled.typeFinal,
|
||||
)
|
||||
if !findings.isEmpty {
|
||||
throw SchemaError.inconsistent(findings.map { String(describing: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -116,6 +116,9 @@ extension PureXML.Schema {
|
||||
/// Each named complex type's base type and derivation method, the backbone
|
||||
/// the `block` check walks from an `xsi:type` to its declared type.
|
||||
var typeDerivation: [String: TypeDerivation] = [:]
|
||||
/// Derivation methods each named type declares `final`, for the
|
||||
/// schema-consistency rules.
|
||||
var typeFinal: [String: Set<DerivationMethod>] = [:]
|
||||
/// The schema's target namespace, so the root element's namespace can be
|
||||
/// checked against it.
|
||||
var targetNamespace: String?
|
||||
|
||||
@ -104,15 +104,6 @@ extension PureXML.Schema.XSDParser {
|
||||
return methods
|
||||
}
|
||||
|
||||
/// Throws when any type derives from a base by a method that base declares
|
||||
/// `final`.
|
||||
static func checkFinal(_ tables: DerivationTables) throws {
|
||||
for (name, derivation) in tables.typeDerivation {
|
||||
guard tables.typeFinal[derivation.base]?.contains(derivation.method) == true else { continue }
|
||||
throw SchemaFault.finalViolation(type: name, base: derivation.base, method: methodName(derivation.method))
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws when a type inside an `xs:redefine` does not derive from itself: a
|
||||
/// redefinition's `base` must name the type being redefined.
|
||||
static func checkRedefine(_ containers: [XSDTree]) throws {
|
||||
|
||||
@ -15,7 +15,10 @@ extension PureXML.Schema {
|
||||
var visited: Set<String> = []
|
||||
let containers = XSDNode.collectContainers(schema, loader, &visited)
|
||||
let derivation = derivationTables(containers)
|
||||
try checkFinal(derivation)
|
||||
// checkRedefine and checkAllGroups examine the raw schema source, the
|
||||
// schema-compilation analog of well-formedness, so they stay throws;
|
||||
// the model-level consistency checks (final, restriction subsets) are
|
||||
// Validation rules collected by Schema.Document.
|
||||
try checkRedefine(containers)
|
||||
try checkAllGroups(containers)
|
||||
var context = XSDContext(
|
||||
@ -58,6 +61,7 @@ extension PureXML.Schema {
|
||||
abstractElements: derivation.abstractElements,
|
||||
typeBlock: derivation.typeBlock,
|
||||
typeDerivation: derivation.typeDerivation,
|
||||
typeFinal: derivation.typeFinal,
|
||||
targetNamespace: context.targetNamespace,
|
||||
)
|
||||
}
|
||||
|
||||
82
Sources/Schema/XSDSchemaValidations.swift
Normal file
82
Sources/Schema/XSDSchemaValidations.swift
Normal file
@ -0,0 +1,82 @@
|
||||
public extension PureXML.Schema {
|
||||
/// One named complex type's compile-time derivation facts: the subject of
|
||||
/// the schema-consistency rules.
|
||||
struct SchemaTypeFact: PureXML.Validation.Validatable {
|
||||
public let name: String
|
||||
public let derivation: TypeDerivation?
|
||||
|
||||
public init(name: String, derivation: TypeDerivation?) {
|
||||
self.name = name
|
||||
self.derivation = derivation
|
||||
}
|
||||
}
|
||||
|
||||
/// The compiled context the schema-consistency rules read: the named-type
|
||||
/// table and each type's `final` controls.
|
||||
struct CompiledSchemaFacts {
|
||||
public let types: [String: ElementType]
|
||||
public let typeFinal: [String: Set<DerivationMethod>]
|
||||
|
||||
public init(types: [String: ElementType], typeFinal: [String: Set<DerivationMethod>]) {
|
||||
self.types = types
|
||||
self.typeFinal = typeFinal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Validation {
|
||||
/// Schema-consistency validation, expressed in the validation framework:
|
||||
/// each named type is a ``PureXML/Schema/SchemaTypeFact`` subject checked by
|
||||
/// composable rules, so a schema with several problems reports them all at
|
||||
/// once instead of failing on the first.
|
||||
enum XSDSchema {
|
||||
/// A type derives from its base only by methods the base permits.
|
||||
static var finalRespected: Validation<PureXML.Schema.SchemaTypeFact, PureXML.Schema.CompiledSchemaFacts> {
|
||||
.init(description: "A type derives from its base only by methods the base permits") { context in
|
||||
guard let derivation = context.subject.derivation,
|
||||
context.document.typeFinal[derivation.base]?.contains(derivation.method) == true
|
||||
else { return [] }
|
||||
let method = methodName(derivation.method)
|
||||
let reason = "type '\(context.subject.name)' derives from '\(derivation.base)' by \(method), which '\(derivation.base)' declares final"
|
||||
return [ValidationError(reason: reason, at: context.codingPath)]
|
||||
}
|
||||
}
|
||||
|
||||
/// A complex type derived by restriction accepts a subset of its base
|
||||
/// ("Particle Valid (Restriction)", XSD 1.0).
|
||||
static var restrictionsAreSubsets: Validation<PureXML.Schema.SchemaTypeFact, PureXML.Schema.CompiledSchemaFacts> {
|
||||
.init(description: "A restriction's content model accepts a subset of its base's") { context in
|
||||
guard let derivation = context.subject.derivation, derivation.method == .restriction,
|
||||
case let .complex(restricted)? = context.document.types[context.subject.name],
|
||||
case let .complex(base)? = context.document.types[derivation.base],
|
||||
let reason = PureXML.Schema.ParticleRestriction.violation(restricted: restricted.content, base: base.content)
|
||||
else { return [] }
|
||||
let text = "type '\(context.subject.name)' is not a valid restriction of '\(derivation.base)': \(reason)"
|
||||
return [ValidationError(reason: text, at: context.codingPath)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Every consistency finding across the schema's named types, each
|
||||
/// located at its type, in deterministic order.
|
||||
static func consistencyErrors(
|
||||
types: [String: PureXML.Schema.ElementType],
|
||||
typeDerivation: [String: PureXML.Schema.TypeDerivation],
|
||||
typeFinal: [String: Set<PureXML.Schema.DerivationMethod>],
|
||||
) -> [ValidationError] {
|
||||
let facts = PureXML.Schema.CompiledSchemaFacts(types: types, typeFinal: typeFinal)
|
||||
let rules = [finalRespected, restrictionsAreSubsets]
|
||||
return typeDerivation.keys.sorted().flatMap { name -> [ValidationError] in
|
||||
let fact = PureXML.Schema.SchemaTypeFact(name: name, derivation: typeDerivation[name])
|
||||
return rules.flatMap { $0.apply(to: fact, at: [.element(name)], in: facts) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func methodName(_ method: PureXML.Schema.DerivationMethod) -> String {
|
||||
switch method {
|
||||
case .extension: "extension"
|
||||
case .restriction: "restriction"
|
||||
case .substitution: "substitution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
Tests/XSDSchemaValidationTests.swift
Normal file
163
Tests/XSDSchemaValidationTests.swift
Normal file
@ -0,0 +1,163 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
/// Unit tests for the schema-consistency validation rules (final respected,
|
||||
/// Particle Valid (Restriction)) exercised directly per the framework idiom,
|
||||
/// plus the collected-findings behavior: a schema with several problems
|
||||
/// reports them all at once instead of failing on the first.
|
||||
@Suite("XSD schema-consistency validation")
|
||||
struct XSDSchemaValidationTests {
|
||||
private typealias Fact = PureXML.Schema.SchemaTypeFact
|
||||
private typealias Facts = PureXML.Schema.CompiledSchemaFacts
|
||||
|
||||
/// Compiles raw XSD source into the parser's tables, bypassing
|
||||
/// `Schema.Document`'s consistency throw, so the rules can be applied
|
||||
/// directly to known-inconsistent schemas.
|
||||
private func compile(_ xsd: String) throws -> PureXML.Schema.XSDCompiled {
|
||||
try PureXML.Schema.XSDParser.parse(xsd) { _ in nil }
|
||||
}
|
||||
|
||||
private let finalViolationSchema = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="ShapeT" final="extension">
|
||||
<xs:sequence><xs:element name="name" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="CircleT">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="ShapeT">
|
||||
<xs:sequence><xs:element name="radius" type="xs:integer"/></xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<xs:element name="s" type="CircleT"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
|
||||
private let badRestrictionSchema = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="B">
|
||||
<xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="R">
|
||||
<xs:complexContent>
|
||||
<xs:restriction base="B">
|
||||
<xs:sequence>
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
<xs:element name="b" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:restriction>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<xs:element name="r" type="R"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
|
||||
@Test("finalRespected reports a derivation the base declares final, and only that")
|
||||
func test_finalRespectedRule() throws {
|
||||
let compiled = try compile(finalViolationSchema)
|
||||
let facts = Facts(types: compiled.types, typeFinal: compiled.typeFinal)
|
||||
let rule = PureXML.Validation.XSDSchema.finalRespected
|
||||
let bad = Fact(name: "CircleT", derivation: compiled.typeDerivation["CircleT"])
|
||||
let errors = rule.apply(to: bad, at: [.element("CircleT")], in: facts)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "type 'CircleT' derives from 'ShapeT' by extension, which 'ShapeT' declares final")
|
||||
// A type with no derivation at all is untouched by the rule.
|
||||
let underived = Fact(name: "ShapeT", derivation: nil)
|
||||
#expect(rule.apply(to: underived, at: [.element("ShapeT")], in: facts).isEmpty)
|
||||
}
|
||||
|
||||
@Test("restrictionsAreSubsets reports an unfaithful restriction with the violation reason")
|
||||
func test_restrictionRule() throws {
|
||||
let compiled = try compile(badRestrictionSchema)
|
||||
let facts = Facts(types: compiled.types, typeFinal: compiled.typeFinal)
|
||||
let rule = PureXML.Validation.XSDSchema.restrictionsAreSubsets
|
||||
let bad = Fact(name: "R", derivation: compiled.typeDerivation["R"])
|
||||
let errors = rule.apply(to: bad, at: [.element("R")], in: facts)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("type 'R' is not a valid restriction of 'B'") == true)
|
||||
// The same rule passes a faithful restriction (identical content model).
|
||||
let faithful = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="B">
|
||||
<xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="R">
|
||||
<xs:complexContent>
|
||||
<xs:restriction base="B">
|
||||
<xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence>
|
||||
</xs:restriction>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<xs:element name="r" type="R"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
let good = try compile(faithful)
|
||||
let goodFacts = Facts(types: good.types, typeFinal: good.typeFinal)
|
||||
let goodFact = Fact(name: "R", derivation: good.typeDerivation["R"])
|
||||
#expect(rule.apply(to: goodFact, at: [.element("R")], in: goodFacts).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A schema with several consistency problems reports them all at once")
|
||||
func test_multiProblemSchemaReportsAll() throws {
|
||||
let xsd = """
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:complexType name="ShapeT" final="extension">
|
||||
<xs:sequence><xs:element name="name" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="CircleT">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="ShapeT">
|
||||
<xs:sequence><xs:element name="radius" type="xs:integer"/></xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="B">
|
||||
<xs:sequence><xs:element name="a" type="xs:string"/></xs:sequence>
|
||||
</xs:complexType>
|
||||
<xs:complexType name="R">
|
||||
<xs:complexContent>
|
||||
<xs:restriction base="B">
|
||||
<xs:sequence>
|
||||
<xs:element name="a" type="xs:string"/>
|
||||
<xs:element name="b" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:restriction>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<xs:element name="s" type="CircleT"/>
|
||||
</xs:schema>
|
||||
"""
|
||||
do {
|
||||
_ = try PureXML.Schema.Document(xsd)
|
||||
Issue.record("compilation should have thrown")
|
||||
} catch let error as PureXML.Schema.SchemaError {
|
||||
guard case let .inconsistent(findings) = error else {
|
||||
Issue.record("expected .inconsistent, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(findings.count == 2)
|
||||
#expect(findings.contains { $0.contains("declares final") })
|
||||
#expect(findings.contains { $0.contains("not a valid restriction") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test("consistencyErrors walks every derived type in deterministic name order")
|
||||
func test_consistencyErrorsOrder() throws {
|
||||
let compiledFinal = try compile(finalViolationSchema)
|
||||
let compiledRestriction = try compile(badRestrictionSchema)
|
||||
let finalErrors = PureXML.Validation.XSDSchema.consistencyErrors(
|
||||
types: compiledFinal.types,
|
||||
typeDerivation: compiledFinal.typeDerivation,
|
||||
typeFinal: compiledFinal.typeFinal,
|
||||
)
|
||||
#expect(finalErrors.count == 1)
|
||||
#expect(finalErrors.first?.codingPath == [.element("CircleT")])
|
||||
let restrictionErrors = PureXML.Validation.XSDSchema.consistencyErrors(
|
||||
types: compiledRestriction.types,
|
||||
typeDerivation: compiledRestriction.typeDerivation,
|
||||
typeFinal: compiledRestriction.typeFinal,
|
||||
)
|
||||
#expect(restrictionErrors.count == 1)
|
||||
#expect(restrictionErrors.first?.codingPath == [.element("R")])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user