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:
Mihaela M 2026-06-28 09:27:17 +02:00
parent 2e45ecaffe
commit 1915a00fde
2 changed files with 219 additions and 0 deletions

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

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