feat(validation): add the combinator algebra for checks
Add the declarative combinators the validation idiom composes rules from, as free functions returning closures: comparison operators (==, !=, <, <=, >, >=) lifting a key path and a literal into a predicate; take to dig to a value and run arbitrary logic; all to apply many checks to one context; lift to run child-typed checks against a value reached by key path; and && / || to compose two checks (concatenating their errors, or short-circuiting so errors surface only when both fail). Both the context-rooted and subject-rooted key-path forms are provided so an author can pin the subject type in either clause. A key path is immutable and read-safe but not declared Sendable, so a @Sendable predicate captures it through a small @unchecked Sendable box. lookup is omitted: it resolves references against a component store the generic model does not carry; a consumer with references supplies it. Each combinator has a pure-function test against a hand-built context, per the idiom's requirement to unit test the operators in isolation.
This commit is contained in:
parent
2e45ecaffe
commit
1915a00fde
134
Sources/Validation/Check.Combinators.swift
Normal file
134
Sources/Validation/Check.Combinators.swift
Normal file
@ -0,0 +1,134 @@
|
||||
// The combinator algebra for type-directed checks (validation-rules.md, "The combinator algebra").
|
||||
// Declarative building blocks composed by value: comparison operators and `take` lift a subject or
|
||||
// context value into a Bool predicate; `&&` / `||` compose checks (concatenating or short-circuiting
|
||||
// their errors); `all` and `lift` apply many checks to one or a child value. `lookup` is omitted: it
|
||||
// resolves references against a component store, which the generic model does not carry; a consumer
|
||||
// with references supplies it over its own document.
|
||||
|
||||
/// A key path is immutable and safe to read concurrently, but the standard library does not declare
|
||||
/// it `Sendable`, so wrap one captured by a `@Sendable` predicate closure.
|
||||
private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
|
||||
let keyPath: KeyPath<Root, Value>
|
||||
}
|
||||
|
||||
// MARK: comparison operators lifting a key path and a literal into a predicate
|
||||
|
||||
public func == <S, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] == rhs }
|
||||
}
|
||||
|
||||
public func != <S, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] != rhs }
|
||||
}
|
||||
|
||||
public func > <S, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] > rhs }
|
||||
}
|
||||
|
||||
public func >= <S, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] >= rhs }
|
||||
}
|
||||
|
||||
public func < <S, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] < rhs }
|
||||
}
|
||||
|
||||
public func <= <S, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] <= rhs }
|
||||
}
|
||||
|
||||
public extension PureYAML.Validation {
|
||||
/// Digs to a value through a key path on the context and runs arbitrary logic, the escape hatch
|
||||
/// beyond equality.
|
||||
static func take<S, U: Sendable>(
|
||||
_ keyPath: KeyPath<SubjectContext<S>, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return { predicate($0[keyPath: boxed.keyPath]) }
|
||||
}
|
||||
|
||||
/// `take` rooted through the subject, so an author can write `take(\.someChild) { ... }`.
|
||||
static func take<S, U: Sendable>(
|
||||
_ keyPath: KeyPath<S, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return { predicate($0.subject[keyPath: boxed.keyPath]) }
|
||||
}
|
||||
|
||||
/// Applies every check to the same context, concatenating their errors.
|
||||
static func all<S>(_ checks: Check<S>...) -> Check<S> {
|
||||
let list = checks
|
||||
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
list.flatMap { $0.apply(context) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs child-typed checks against a value reached through a key path, keeping the parent's path
|
||||
/// and document.
|
||||
static func lift<S, Child: Validatable>(
|
||||
_ keyPath: KeyPath<S, Child>,
|
||||
into checks: Check<Child>...,
|
||||
) -> Check<S> {
|
||||
let list = checks
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
let childContext = SubjectContext(
|
||||
document: context.document,
|
||||
subject: context.subject[keyPath: boxed.keyPath],
|
||||
path: context.path,
|
||||
)
|
||||
return list.flatMap { $0.apply(childContext) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: composing two checks (&& concatenates errors, || short-circuits)
|
||||
|
||||
public func && <S>(
|
||||
lhs: PureYAML.Validation.Check<S>,
|
||||
rhs: PureYAML.Validation.Check<S>,
|
||||
) -> PureYAML.Validation.Check<S> {
|
||||
PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in
|
||||
lhs.apply(context) + rhs.apply(context)
|
||||
}
|
||||
}
|
||||
|
||||
public func || <S>(
|
||||
lhs: PureYAML.Validation.Check<S>,
|
||||
rhs: PureYAML.Validation.Check<S>,
|
||||
) -> PureYAML.Validation.Check<S> {
|
||||
PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in
|
||||
let left = lhs.apply(context)
|
||||
guard !left.isEmpty else {
|
||||
return []
|
||||
}
|
||||
let right = rhs.apply(context)
|
||||
return right.isEmpty ? [] : left + right
|
||||
}
|
||||
}
|
||||
85
Tests/ValidationCombinatorTests.swift
Normal file
85
Tests/ValidationCombinatorTests.swift
Normal file
@ -0,0 +1,85 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
private struct Address: PureYAML.Validation.Validatable {
|
||||
var city: String
|
||||
}
|
||||
|
||||
private struct Person: PureYAML.Validation.Validatable {
|
||||
var name: String
|
||||
var age: Int
|
||||
var address: Address
|
||||
}
|
||||
|
||||
@Suite("Validation combinators")
|
||||
struct ValidationCombinatorTests {
|
||||
private typealias Context = PureYAML.Validation.SubjectContext<Person>
|
||||
|
||||
private func context(name: String = "Ada", age: Int = 30, city: String = "Lovelace") -> Context {
|
||||
.init(document: .null, subject: Person(name: name, age: age, address: Address(city: city)), path: .root)
|
||||
}
|
||||
|
||||
private func failing(_ description: String) -> PureYAML.Validation.Check<Person> {
|
||||
.init(description: description) { ctx in
|
||||
[PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)]
|
||||
}
|
||||
}
|
||||
|
||||
private func passing(_ description: String) -> PureYAML.Validation.Check<Person> {
|
||||
.init(description: description) { _ in [] }
|
||||
}
|
||||
|
||||
@Test("Comparison operators lift a key path and a literal into a predicate")
|
||||
func test_comparisonOperators() {
|
||||
let isAdult = (\Context.subject.age) >= 18
|
||||
#expect(isAdult(context(age: 30)) == true)
|
||||
#expect(isAdult(context(age: 17)) == false)
|
||||
let named = (\Context.subject.name) == "Ada"
|
||||
#expect(named(context(name: "Ada")) == true)
|
||||
#expect(named(context(name: "Bob")) == false)
|
||||
}
|
||||
|
||||
@Test("take digs to a value and runs arbitrary logic")
|
||||
func test_take() {
|
||||
let cityIsSet = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty }
|
||||
#expect(cityIsSet(context(city: "Lovelace")) == true)
|
||||
#expect(cityIsSet(context(city: "")) == false)
|
||||
}
|
||||
|
||||
@Test("A Bool check built from an operator auto-formats its failure")
|
||||
func test_boolCheckFromOperator() {
|
||||
let adult = PureYAML.Validation.Check<Person>(description: "Person is an adult", check: (\Context.subject.age) >= 18)
|
||||
#expect(adult.apply(context(age: 30)).isEmpty)
|
||||
let issues = adult.apply(context(age: 10))
|
||||
#expect(issues.count == 1)
|
||||
#expect(issues.first?.reason == "Failed to satisfy: Person is an adult")
|
||||
}
|
||||
|
||||
@Test("all applies every check to the same context, concatenating errors")
|
||||
func test_all() {
|
||||
let combined = PureYAML.Validation.all(failing("one"), passing("ok"), failing("two"))
|
||||
#expect(combined.apply(context()).map(\.reason) == ["one", "two"])
|
||||
}
|
||||
|
||||
@Test("&& concatenates both error lists")
|
||||
func test_andConcatenates() {
|
||||
let combined = failing("a") && failing("b")
|
||||
#expect(combined.apply(context()).map(\.reason) == ["a", "b"])
|
||||
#expect((passing("a") && passing("b")).apply(context()).isEmpty)
|
||||
}
|
||||
|
||||
@Test("|| short-circuits and errors only when both fail")
|
||||
func test_orShortCircuits() {
|
||||
#expect((passing("a") || failing("b")).apply(context()).isEmpty)
|
||||
#expect((failing("a") || passing("b")).apply(context()).isEmpty)
|
||||
#expect((failing("a") || failing("b")).apply(context()).map(\.reason) == ["a", "b"])
|
||||
}
|
||||
|
||||
@Test("lift runs child-typed checks against a value reached by key path")
|
||||
func test_lift() {
|
||||
let cityNotEmpty = PureYAML.Validation.Check<Address>(description: "City is set", check: { !$0.subject.city.isEmpty })
|
||||
let lifted = PureYAML.Validation.lift(\Person.address, into: cityNotEmpty)
|
||||
#expect(lifted.apply(context(city: "Lovelace")).isEmpty)
|
||||
#expect(lifted.apply(context(city: "")).count == 1)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user