PureXML/Sources/Validation/Validation.swift
Mihaela Mihaljevic 1fbe359ada feat(validation): composable validation framework + structural rule
Slice 1 of #92. Introduce the OpenAPIKit-idiom validation framework, replacing
the imperative if-tree structural validator:

- ValidationError (reason + coding path) renders "<reason> at path: root/row/cell";
  ValidationErrorCollection gathers every failure and is thrown once.
- Validation<Subject, Document> is the atomic, composable rule value, with the
  multi-error and single-error Bool inits (the latter auto-forms "Failed to
  satisfy: <description>"). Predicate-gated, type-directed via an erased
  AnyValidation that guards an optional from matching a non-optional rule.
- The framework is parameterized over both Subject and Document so the structural,
  DTD, XSD, and Schematron layers share one machinery (Document = Void for rules
  that need only their subject).
- Validator<Document> walks the node tree, building the coding path, with blank /
  default factories and fluent validating / withoutValidating.
- The combinator algebra (&&, ||, comparison lifts, take, lift, unwrap, all) as
  free functions returning closures.

The structural duplicate-attribute rule is reborn as Validation<Element, Void>.
Combinators and the erased wrapper are unit-tested as pure functions per the rule.

The legacy Validation.Issue stays only for the not-yet-migrated schema validators
and is retired in a later slice.
2026-06-08 12:00:34 +02:00

74 lines
3.5 KiB
Swift

public extension PureXML.Validation {
/// An atomic validation: a positive `description` of the correct state, a
/// `validate` function returning one ``ValidationError`` per problem, and a
/// `predicate` that scopes when the rule applies. Validations are composable
/// values, combined with the operator algebra, never imperative if-trees.
///
/// Specialized on `Subject` (the value type it fires on, anywhere in the tree)
/// and parameterized over `Document` (the cross-cutting context, or `Void`
/// when a rule needs nothing beyond its subject).
struct Validation<Subject: Validatable, Document> {
public let validate: (ValidationContext<Subject, Document>) -> [ValidationError]
public let predicate: (ValidationContext<Subject, Document>) -> Bool
public let description: String
/// Runs the validation against `subject`, gating on the predicate first.
public func apply(to subject: Subject, at codingPath: [PathKey], in document: Document) -> [ValidationError] {
let context = ValidationContext(document: document, subject: subject, codingPath: codingPath)
guard predicate(context) else { return [] }
return validate(context)
}
/// The multi-error form: return one error per problem. Use when a single
/// value can fail in several places at once.
public init(
description: String? = nil,
check validate: @escaping (ValidationContext<Subject, Document>) -> [ValidationError],
when predicate: @escaping (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.validate = validate
self.predicate = predicate
self.description = description ?? String(describing: Subject.self)
}
/// The single-error Bool form (the dominant one): a positive `description`
/// and a check returning `false` when invalid, which auto-produces the
/// error `"Failed to satisfy: <description>"`.
public init(
description: String,
check validate: @escaping (ValidationContext<Subject, Document>) -> Bool,
when predicate: @escaping (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
validate(context) ? [] : [ValidationError(reason: "Failed to satisfy: \(description)", at: context.codingPath)]
},
when: predicate,
)
}
}
}
extension PureXML.Validation {
/// Erases the subject type of a ``Validation`` so a heterogeneous list can be
/// applied at every node. Filters by runtime type, guarding against an
/// optional satisfying a non-optional validation.
struct AnyValidation<Document> {
let description: String
private let applyErased: (Any, [PathKey], Document) -> [ValidationError]
init<Subject>(_ validation: Validation<Subject, Document>) {
description = validation.description
applyErased = { input, codingPath, document in
guard let subject = input as? Subject, type(of: subject) == type(of: input) else { return [] }
return validation.apply(to: subject, at: codingPath, in: document)
}
}
func apply(to value: Any, at codingPath: [PathKey], in document: Document) -> [ValidationError] {
applyErased(value, codingPath, document)
}
}
}