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:
Mihaela Mihaljevic 2026-06-10 06:04:40 +02:00
parent 32d42410b9
commit c8f91e329c
8 changed files with 271 additions and 31 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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) })
}
}

View File

@ -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?

View File

@ -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 {

View File

@ -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,
)
}

View 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"
}
}
}
}

View 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")])
}
}