From 0c9136036890f5c6dd2e4a831a98071f15f25cf2 Mon Sep 17 00:00:00 2001 From: Kacper Donat Date: Tue, 27 Oct 2026 23:02:34 +0100 Subject: [PATCH] compile predicates and pipelines --- src/Predicate/AllOfPredicate.php | 22 +++++- src/Predicate/AnyOfPredicate.php | 22 +++++- src/Predicate/ClosurePredicate.php | 20 ++++- src/Predicate/ConstantPredicate.php | 10 ++- src/Predicate/NegatedPredicate.php | 12 ++- src/Predicates/comparisons.php | 12 +-- src/Predicates/objects.php | 2 +- src/Utils/FunctionPipeline.php | 48 +++++++++++- src/Utils/index.php | 12 ++- tests/CompileTest.php | 115 ++++++++++++++++++++++++++++ 10 files changed, 258 insertions(+), 17 deletions(-) diff --git a/src/Predicate/AllOfPredicate.php b/src/Predicate/AllOfPredicate.php index 9941e2f..033d3a0 100644 --- a/src/Predicate/AllOfPredicate.php +++ b/src/Predicate/AllOfPredicate.php @@ -10,9 +10,12 @@ namespace Kadet\Functional\Predicate; +use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; -class AllOfPredicate extends AbstractPredicate +use function Kadet\Functional\compile; + +class AllOfPredicate extends AbstractPredicate implements Compilable { /** * Array of predicates to check if all are satisfied. @@ -41,4 +44,19 @@ class AllOfPredicate extends AbstractPredicate return true; } -} \ No newline at end of file + + public function compile(): \Closure + { + $predicates = array_map(compile(...), $this->predicates); + + return static function (mixed ...$args) use ($predicates): bool { + foreach ($predicates as $predicate) { + if (!$predicate(...$args)) { + return false; + } + } + + return true; + }; + } +} diff --git a/src/Predicate/AnyOfPredicate.php b/src/Predicate/AnyOfPredicate.php index 35a184c..1ec5e21 100644 --- a/src/Predicate/AnyOfPredicate.php +++ b/src/Predicate/AnyOfPredicate.php @@ -10,9 +10,12 @@ namespace Kadet\Functional\Predicate; +use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; -class AnyOfPredicate extends AbstractPredicate +use function Kadet\Functional\compile; + +class AnyOfPredicate extends AbstractPredicate implements Compilable { /** * Array of predicates to check if all are satisfied. @@ -42,4 +45,19 @@ class AnyOfPredicate extends AbstractPredicate return false; } -} \ No newline at end of file + + public function compile(): \Closure + { + $predicates = array_map(compile(...), $this->predicates); + + return static function (mixed ...$args) use ($predicates): bool { + foreach ($predicates as $predicate) { + if ($predicate(...$args)) { + return true; + } + } + + return false; + }; + } +} diff --git a/src/Predicate/ClosurePredicate.php b/src/Predicate/ClosurePredicate.php index 1522773..bca0134 100644 --- a/src/Predicate/ClosurePredicate.php +++ b/src/Predicate/ClosurePredicate.php @@ -10,9 +10,12 @@ namespace Kadet\Functional\Predicate; +use Kadet\Functional\Compilable; use Kadet\Functional\Decorator; -class ClosurePredicate extends AbstractPredicate implements Decorator +use function Kadet\Functional\compile; + +class ClosurePredicate extends AbstractPredicate implements Decorator, Compilable { /** * @var \Closure @@ -38,4 +41,19 @@ class ClosurePredicate extends AbstractPredicate implements Decorator { return $this->closure; } + + /** + * A closure already declared to return bool is returned as it is (compiled, if it wraps a Compilable). + */ + public function compile(): \Closure + { + $compiled = compile($this->closure); + $type = (new \ReflectionFunction($compiled))->getReturnType(); + + if ($type instanceof \ReflectionNamedType && $type->getName() === 'bool' && !$type->allowsNull()) { + return $compiled; + } + + return static fn (mixed ...$args): bool => $compiled(...$args); + } } \ No newline at end of file diff --git a/src/Predicate/ConstantPredicate.php b/src/Predicate/ConstantPredicate.php index 6a5a480..9ff38a2 100644 --- a/src/Predicate/ConstantPredicate.php +++ b/src/Predicate/ConstantPredicate.php @@ -11,9 +11,10 @@ namespace Kadet\Functional\Predicate; use function Kadet\Functional\{ all, any }; +use Kadet\Functional\Compilable; use Kadet\Functional\Predicate; -class ConstantPredicate extends AbstractPredicate +class ConstantPredicate extends AbstractPredicate implements Compilable { /** @var boolean */ private $value; @@ -58,6 +59,13 @@ class ConstantPredicate extends AbstractPredicate return $this->value; } + public function compile(): \Closure + { + $value = $this->value; + + return static fn (mixed ...$args): bool => $value; + } + public static function always() { static $always; diff --git a/src/Predicate/NegatedPredicate.php b/src/Predicate/NegatedPredicate.php index 82cba24..3e89103 100644 --- a/src/Predicate/NegatedPredicate.php +++ b/src/Predicate/NegatedPredicate.php @@ -10,10 +10,13 @@ namespace Kadet\Functional\Predicate; +use Kadet\Functional\Compilable; use Kadet\Functional\Decorator; use Kadet\Functional\Predicate; -class NegatedPredicate extends AbstractPredicate implements Decorator +use function Kadet\Functional\compile; + +class NegatedPredicate extends AbstractPredicate implements Decorator, Compilable { /** * Predicate to be negated. @@ -41,4 +44,11 @@ class NegatedPredicate extends AbstractPredicate implements Decorator { return $this->negated; } + + public function compile(): \Closure + { + $negated = compile($this->negated); + + return static fn (mixed ...$args): bool => !$negated(...$args); + } } \ No newline at end of file diff --git a/src/Predicates/comparisons.php b/src/Predicates/comparisons.php index 2de7d9d..0151604 100644 --- a/src/Predicates/comparisons.php +++ b/src/Predicates/comparisons.php @@ -19,7 +19,7 @@ use function Kadet\Functional\predicate; */ function equals($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $expected == $value; }); } @@ -31,7 +31,7 @@ function equals($expected): Predicate */ function same($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $value === $expected; }); } @@ -43,7 +43,7 @@ function same($expected): Predicate */ function gt($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $value > $expected; }); } @@ -55,7 +55,7 @@ function gt($expected): Predicate */ function ge($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $value >= $expected; }); } @@ -67,7 +67,7 @@ function ge($expected): Predicate */ function lt($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $value < $expected; }); } @@ -79,7 +79,7 @@ function lt($expected): Predicate */ function le($expected): Predicate { - return predicate(function ($value) use ($expected) { + return predicate(function ($value) use ($expected): bool { return $value <= $expected; }); } diff --git a/src/Predicates/objects.php b/src/Predicates/objects.php index 07b7c88..700a5a5 100644 --- a/src/Predicates/objects.php +++ b/src/Predicates/objects.php @@ -54,7 +54,7 @@ function field(string $field, $predicate) */ function instance(string $class) { - return new ClosurePredicate(function ($value) use ($class) { + return new ClosurePredicate(function ($value) use ($class): bool { return $value instanceof $class; }); } \ No newline at end of file diff --git a/src/Utils/FunctionPipeline.php b/src/Utils/FunctionPipeline.php index 216535f..34e3780 100644 --- a/src/Utils/FunctionPipeline.php +++ b/src/Utils/FunctionPipeline.php @@ -9,8 +9,11 @@ namespace Kadet\Functional\Utils; +use Kadet\Functional\Compilable; -class FunctionPipeline +use function Kadet\Functional\compile; + +class FunctionPipeline implements Compilable { /** * @var callable[] @@ -45,4 +48,47 @@ class FunctionPipeline : $callable($arg); }, new UnpackedArgs($args)); } + + /** + * The first function gets all arguments, each next one the result of the previous one (spread when it is + * UnpackedArgs), like invoking the pipeline. + */ + public function compile(): \Closure + { + if (empty($this->functions)) { + return static fn (...$args) => new UnpackedArgs($args); + } + + // A curried function decides on its own whether to call or keep currying, so it is called as is. + $functions = array_map( + static fn (callable $function) => $function instanceof CurriedFunction + ? static fn (...$args) => $function(...$args) + : compile($function), + $this->functions, + ); + $first = array_shift($functions); + + if (empty($functions)) { + return $first; + } + + if (count($functions) === 1) { + $next = $functions[0]; + + return static function (...$args) use ($first, $next) { + $result = $first(...$args); + + return $result instanceof UnpackedArgs ? $next(...$result->getArrayCopy()) : $next($result); + }; + } + + return static function (...$args) use ($first, $functions) { + $result = $first(...$args); + foreach ($functions as $function) { + $result = $result instanceof UnpackedArgs ? $function(...$result->getArrayCopy()) : $function($result); + } + + return $result; + }; + } } \ No newline at end of file diff --git a/src/Utils/index.php b/src/Utils/index.php index b04ee1a..b7ee398 100644 --- a/src/Utils/index.php +++ b/src/Utils/index.php @@ -68,10 +68,18 @@ function memoize(callable $callable) } /** - * Flattens a callable into a plain closure. Compilable decorators are compiled, so calling the result does not go - * through them; any other callable is converted with closure(). + * Flattens a callable into a plain closure. Compilable objects (decorators, pipelines, predicates) are compiled, so + * calling the result does not go through them, also when given as a closure of their `__invoke` (e.g. `$pipe(...)`); + * any other callable is converted with closure(). */ function compile(callable $callable): \Closure { + if ($callable instanceof \Closure) { + $reflection = new \ReflectionFunction($callable); + $bound = $reflection->getClosureThis(); + + return $bound instanceof Compilable && $reflection->getName() === '__invoke' ? $bound->compile() : $callable; + } + return $callable instanceof Compilable ? $callable->compile() : closure($callable); } \ No newline at end of file diff --git a/tests/CompileTest.php b/tests/CompileTest.php index 44e4874..781ce0e 100644 --- a/tests/CompileTest.php +++ b/tests/CompileTest.php @@ -10,7 +10,11 @@ use Kadet\Functional as f; use Kadet\Functional\Compilable; use Kadet\Functional\Decorator; +use Kadet\Functional\Predicate\ClosurePredicate; +use Kadet\Functional\Predicates as p; +use Kadet\Functional\Utils\FunctionPipeline; use Kadet\Functional\Utils\PartiallyAppliedFunction; +use Kadet\Functional\Utils\UnpackedArgs; use const Kadet\Functional\_; class CompileTest extends \PHPUnit\Framework\TestCase @@ -177,4 +181,115 @@ class CompileTest extends \PHPUnit\Framework\TestCase $this->assertSame(6, $compiled(3)); $this->assertSame(24, $compiled(4)); } + + public function testPipelineAndPredicatesAreCompilable() + { + $this->assertInstanceOf(Compilable::class, f\pipe('strtoupper')); + $this->assertInstanceOf(Compilable::class, f\predicate(fn ($x) => $x)); + $this->assertInstanceOf(Compilable::class, f\not(fn ($x) => $x)); + $this->assertInstanceOf(Compilable::class, f\all(fn ($x) => $x)); + $this->assertInstanceOf(Compilable::class, f\any(fn ($x) => $x)); + $this->assertInstanceOf(Compilable::class, f\always()); + } + + public function testCompiledPipelineDoesNotInvokePipeline() + { + $spy = new class(trim(...), strtoupper(...)) extends FunctionPipeline { + public int $calls = 0; + + public function __invoke(...$args) + { + $this->calls++; + return parent::__invoke(...$args); + } + }; + + $compiled = $spy->compile(); + + $this->assertSame('A', $compiled(' a ')); + $this->assertSame(0, $spy->calls); + } + + public function testCompiledPipelineMatchesPipeline() + { + $pipelines = [ + f\pipe(), + f\pipe(fn (...$args) => $args), + f\pipe(trim(...), strtoupper(...)), + f\pipe(fn ($a, $b) => $a + $b, fn ($x) => $x * 2, fn ($x) => $x - 1), + f\pipe(fn ($a, $b) => new UnpackedArgs([$b, $a]), fn ($a, $b) => "$a$b"), + ]; + + foreach ($pipelines as $pipeline) { + $this->assertEquals($pipeline(1, 2), f\compile($pipeline)(1, 2)); + } + } + + public function testCompiledPipelineKeepsCurrying() + { + $pipeline = f\pipe(strtoupper(...), f\curry(fn ($a, $b) => "$a$b")); + + $this->assertSame($pipeline('a')('b'), f\compile($pipeline)('a')('b')); + } + + public function testCompileUnwrapsClosureOfCompilable() + { + $spy = new class(strtoupper(...)) extends FunctionPipeline { + public int $calls = 0; + + public function __invoke(...$args) + { + $this->calls++; + return parent::__invoke(...$args); + } + }; + + $this->assertSame('A', f\compile(\Closure::fromCallable($spy))('a')); + $this->assertSame('A', f\compile($spy(...))('a')); + $this->assertSame(0, $spy->calls); + } + + public function testCompiledPredicatesMatchPredicates() + { + $values = [0, 1, 2, 3, '1', 'x']; + $cases = [ + [f\predicate(fn ($x) => $x), $values], + [f\predicate(is_numeric(...)), $values], + [p\gt(1), $values], + [p\gt(1)->not(), $values], + [f\not(fn ($x) => $x > 1), $values], + [f\all(p\gt(0), p\lt(3)), $values], + [f\any(p\lt(1), p\gt(2)), $values], + [f\always(), $values], + [f\never(), $values], + [p\property('x', p\gt(1)), [(object) ['x' => 1], (object) ['x' => 2]]], + ]; + + foreach ($cases as [$predicate, $inputs]) { + $compiled = f\compile($predicate); + + foreach ($inputs as $value) { + $this->assertSame($predicate($value), $compiled($value)); + } + } + } + + public function testCompiledPredicateDoesNotInvokePredicate() + { + $inner = new class(fn ($x) => $x > 1) extends ClosurePredicate { + public int $calls = 0; + + public function __invoke(...$args): bool + { + $this->calls++; + return parent::__invoke(...$args); + } + }; + + $compiled = f\compile(f\all($inner->not(), p\lt(5))); + + $this->assertTrue($compiled(1)); + $this->assertFalse($compiled(2)); + $this->assertSame(0, $inner->calls); + } }