PureXML/Sources/Validation/ValidationCombinators.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

149 lines
5.5 KiB
Swift

// The declarative combinator algebra for ``PureXML/Validation/Validation``.
// Free functions returning closures, so validations compose by value rather than
// through imperative control flow. Generic over both the subject type and the
// document, per the one-framework-parameterized-over-the-document rule.
typealias Validatable = PureXML.Validation.Validatable
typealias VContext<Subject: PureXML.Validation.Validatable, Document> =
PureXML.Validation.ValidationContext<Subject, Document>
typealias VError = PureXML.Validation.ValidationError
typealias VRule<Subject: PureXML.Validation.Validatable, Document> =
PureXML.Validation.Validation<Subject, Document>
// MARK: Context -> error array
func && <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> [VError],
rhs: @escaping (VContext<T, D>) -> [VError],
) -> (VContext<T, D>) -> [VError] {
{ context in lhs(context) + rhs(context) }
}
func || <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> [VError],
rhs: @escaping (VContext<T, D>) -> [VError],
) -> (VContext<T, D>) -> [VError] {
{ context in
let left = lhs(context)
if left.isEmpty {
return []
}
let right = rhs(context)
return right.isEmpty ? [] : left + right
}
}
// MARK: Context -> Bool
func && <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> Bool,
rhs: @escaping (VContext<T, D>) -> Bool,
) -> (VContext<T, D>) -> Bool {
{ context in lhs(context) && rhs(context) }
}
func || <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> Bool,
rhs: @escaping (VContext<T, D>) -> Bool,
) -> (VContext<T, D>) -> Bool {
{ context in lhs(context) || rhs(context) }
}
// MARK: Subject KeyPath -> Bool
func == <T: Validatable, U: Equatable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] == rhs }
}
func != <T: Validatable, U: Equatable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] != rhs }
}
func > <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] > rhs }
}
func >= <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] >= rhs }
}
func < <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] < rhs }
}
func <= <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] <= rhs }
}
// MARK: Digging and lifting
/// Dig to a value by KeyPath and run arbitrary logic on it.
func take<T: Validatable, U, D>(_ path: KeyPath<T, U>, check: @escaping (U) -> Bool) -> (VContext<T, D>) -> Bool {
{ check($0.subject[keyPath: path]) }
}
/// Run child-typed validations against a lifted value, keeping the parent path
/// and document.
func lift<T: Validatable, U: Validatable, D>(
_ path: KeyPath<T, U>,
into validations: VRule<U, D>...,
) -> (VContext<T, D>) -> [VError] {
{ context in
validations.flatMap { $0.apply(to: context.subject[keyPath: path], at: context.codingPath, in: context.document) }
}
}
/// Unwrap an optional, erroring if nil, else run child validations.
func unwrap<T: Validatable, U: Validatable, D>(
_ path: KeyPath<T, U?>,
into validations: VRule<U, D>...,
description: String? = nil,
) -> (VContext<T, D>) -> [VError] {
{ context in
guard let subject = context.subject[keyPath: path] else {
let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil"
return [VError(reason: reason, at: context.codingPath)]
}
return validations.flatMap { $0.apply(to: subject, at: context.codingPath, in: context.document) }
}
}
/// Apply many validations to the same context (`lift` with `\.self`).
func all<T: Validatable, D>(_ validations: VRule<T, D>...) -> (VContext<T, D>) -> [VError] {
{ context in validations.flatMap { $0.apply(to: context.subject, at: context.codingPath, in: context.document) } }
}
/// Resolve a named value from the document, error if missing, else validate the resolved value.
func lookup<T: Validatable, U: Validatable, D>(
_ path: KeyPath<D, [String: U]>,
name: KeyPath<T, String>,
missing: @escaping (String) -> String,
into validations: VRule<U, D>...,
) -> (VContext<T, D>) -> [VError] {
{ context in
let key = context.subject[keyPath: name]
guard let resolved = context.document[keyPath: path][key] else {
return [VError(reason: missing(key), at: context.codingPath)]
}
return validations.flatMap { $0.apply(to: resolved, at: context.codingPath, in: context.document) }
}
}
/// Resolve an optional named reference from the document: a nil reference is
/// not an error, a named-but-unresolved reference errors, else validate the
/// resolved value.
func unwrapAndLookup<T: Validatable, U: Validatable, D>(
_ path: KeyPath<D, [String: U]>,
name: KeyPath<T, String?>,
missing: @escaping (String) -> String,
into validations: VRule<U, D>...,
) -> (VContext<T, D>) -> [VError] {
{ context in
guard let key = context.subject[keyPath: name] else { return [] }
guard let resolved = context.document[keyPath: path][key] else {
return [VError(reason: missing(key), at: context.codingPath)]
}
return validations.flatMap { $0.apply(to: resolved, at: context.codingPath, in: context.document) }
}
}