Initial commit after splitting from main project
PHP Composer / build (push) Successful in 38s

This commit is contained in:
2026-07-26 17:27:01 +02:00
commit afc5538e56
24 changed files with 1431 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Combines callables using right-to-left mathematical composition (f∘g∘h).
* The rightmost callable is applied first — the mathematical counterpart to Pipeline.
*
* Compose::build(f, g, h) is equivalent to Pipeline::build(h, g, f).
* An empty composition returns the value unchanged.
*/
final class Compose
{
/**
* Applies $fns to $value right-to-left immediately and returns the result.
*/
public static function apply(mixed $value, callable ...$fns): mixed
{
foreach (array_reverse($fns) as $fn) {
$value = $fn($value);
}
return $value;
}
/**
* Builds a reusable right-to-left composition closure.
* The returned Closure is compatible with Memoize::wrap() and Retry::run().
*/
public static function build(callable ...$fns): \Closure
{
$reversed = array_reverse($fns);
return static function (mixed $value) use ($reversed): mixed {
foreach ($reversed as $fn) {
$value = $fn($value);
}
return $value;
};
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Wraps a callable and caches its return value per unique argument signature.
* Null return values are cached correctly and do not trigger a second invocation.
*
* Note: argument and return types are intentionally untyped — a variadic memoizer
* cannot express the wrapped callable's full signature at the type-system level.
*/
final class Memoize
{
/** @var callable $fn */
private $fn;
/** @var array<string, mixed> */
private array $cache = [];
/** */
private function __construct(callable $fn)
{
$this->fn = $fn;
}
public static function wrap(callable $fn): self
{
return new self($fn);
}
public function __invoke(mixed ...$args): mixed
{
$key = serialize($args);
if (!array_key_exists($key, $this->cache)) {
$this->cache[$key] = ($this->fn)(...$args);
}
return $this->cache[$key];
}
public function flush(): void
{
$this->cache = [];
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Wraps a callable so it is executed exactly once.
* Every subsequent call returns the cached return value without re-invoking the callable.
*
* @template TReturn
*/
final class Once
{
/** @var callable(): TReturn */
private $fn;
private bool $called = false;
private mixed $result = null;
/** @param callable(): TReturn $fn */
private function __construct(callable $fn)
{
$this->fn = $fn;
}
/**
* @template T
* @param callable(): T $fn
* @return self<T>
*/
public static function wrap(callable $fn): self
{
return new self($fn);
}
/** @return TReturn */
public function __invoke(): mixed
{
if (!$this->called) {
$this->result = ($this->fn)();
$this->called = true;
}
return $this->result;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Pre-fills the leading arguments of a callable and returns a new Closure
* that accepts the remaining arguments.
*/
final class Partial
{
/**
* Returns a Closure with $partial pre-filled as the leftmost arguments.
* The returned Closure forwards any additional arguments to $fn.
*/
public static function apply(callable $fn, mixed ...$partial): \Closure
{
return static function () use ($fn, $partial): mixed {
return $fn(...$partial, ...func_get_args());
};
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Pipes a value through an ordered sequence of callables left-to-right.
* Each stage receives the return value of the previous stage.
* An empty pipeline returns the payload unchanged.
*/
final class Pipeline
{
/**
* Passes $payload through each stage immediately and returns the result.
*/
public static function process(mixed $payload, callable ...$stages): mixed
{
foreach ($stages as $stage) {
$payload = $stage($payload);
}
return $payload;
}
/**
* Builds a reusable pipeline closure that can be invoked later.
* The returned Closure is compatible with Memoize::wrap() and Retry::run().
*/
public static function build(callable ...$stages): \Closure
{
return static function (mixed $payload) use ($stages): mixed {
foreach ($stages as $stage) {
$payload = $stage($payload);
}
return $payload;
};
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
final class Retry
{
/**
* Executes a callable, retrying on exception up to $maxAttempts times.
*
* @template TReturn
* @param callable(): TReturn $fn
* @param list<class-string<\Throwable>> $only Retry only for these exception types (empty = any)
* @param list<class-string<\Throwable>> $except Never retry for these exception types
*
* @throws \Throwable the last exception when all attempts are exhausted
* @return TReturn
*/
public static function run(
callable $fn,
int $maxAttempts = 3,
int $backoffMs = 0,
bool $exponential = false,
array $only = [],
array $except = [],
): mixed {
$attempt = 0;
$lastError = null;
while ($attempt < $maxAttempts) {
try {
return $fn();
} catch (\Throwable $e) {
$lastError = $e;
if (!self::shouldRetry($e, $only, $except)) {
throw $e;
}
$attempt++;
if ($attempt < $maxAttempts && $backoffMs > 0) {
$delay = $exponential
? $backoffMs * (2 ** ($attempt - 1))
: $backoffMs * $attempt;
usleep($delay * 1000);
}
}
}
throw $lastError ?? new \RuntimeException('Retry failed without exception.');
}
/**
* Determines whether the given exception should trigger a retry.
* Returns false immediately if $only is set and the exception does not match,
* or if the exception matches any entry in $except.
*
* @param list<class-string<\Throwable>> $only
* @param list<class-string<\Throwable>> $except
*/
private static function shouldRetry(\Throwable $e, array $only, array $except): bool
{
if ($only !== []) {
$matchesOnly = false;
foreach ($only as $type) {
if ($e instanceof $type) {
$matchesOnly = true;
break;
}
}
if (!$matchesOnly) {
return false;
}
}
foreach ($except as $type) {
if ($e instanceof $type) {
return false;
}
}
return true;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Fn;
/**
* Executes a side-effect callable on a value and returns the value unchanged.
* The return value of $fn is intentionally discarded.
*/
final class Tap
{
/**
* Calls $fn($value) for its side-effect, then returns $value unchanged.
*
* @template T
* @param T $value
* @return T
*/
public static function value(mixed $value, callable $fn): mixed
{
$fn($value);
return $value;
}
}