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

This commit is contained in:
René Halberstadt
2026-07-26 17:27:01 +02:00
commit afc5538e56
24 changed files with 1431 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
name: PHP Composer
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: PHPStan
run: composer phpstan
- name: Code style check
run: composer cs-check
- name: Tests
run: composer test
+3
View File
@@ -0,0 +1,3 @@
/vendor/
/.php-cs-fixer.cache
/.phpunit.result.cache
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PER-CS2.0' => true,
'@PHP83Migration' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
'single_quote' => true,
'explicit_string_variable' => true,
'array_syntax' => ['syntax' => 'short'],
'trim_array_spaces' => true,
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
'declare_strict_types' => true,
'strict_param' => true,
'strict_comparison' => true,
'phpdoc_align' => ['align' => 'left'],
'phpdoc_order' => true,
'phpdoc_trim' => true,
'phpdoc_scalar' => true,
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
'yoda_style' => false,
'no_useless_else' => true,
'no_useless_return' => true,
'concat_space' => ['spacing' => 'one'],
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
])
->setFinder($finder);
+9
View File
@@ -0,0 +1,9 @@
FROM php:8.3-cli-alpine
RUN apk add --no-cache git unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN mkdir -p /tmp/composer && chown 1000:1000 /tmp/composer
WORKDIR /app
+29
View File
@@ -0,0 +1,29 @@
PHP = docker compose run --rm php
.PHONY: install test phpstan cs-fix cs-check audit shell build
build:
docker compose build
install:
$(PHP) composer install
test:
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
phpstan:
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
cs-fix:
$(PHP) vendor/bin/php-cs-fixer fix --ansi
cs-check:
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
audit:
$(PHP) composer audit
ci: audit phpstan cs-check test
shell:
docker compose run --rm php sh
+237
View File
@@ -0,0 +1,237 @@
# tez-utils-fn
Functional programming primitives for PHP: pipelines, composition, memoization, partial application, once-guards, retry with backoff, and tap for side-effects. PHP 8.3+, no dependencies.
## Installation
```bash
composer require tez/utils-fn
```
## Components
| Class | Purpose |
|-------|---------|
| `Pipeline` | Passes a value through callables left-to-right |
| `Compose` | Combines callables right-to-left (mathematical f∘g∘h) |
| `Memoize` | Caches return values per argument signature |
| `Once` | Executes a callable exactly once, returns cached result on subsequent calls |
| `Partial` | Pre-fills leading arguments of a callable |
| `Retry` | Retries a callable on exception with optional backoff and exception filtering |
| `Tap` | Executes a side-effect on a value and returns the value unchanged |
---
## Pipeline
Passes a value through an ordered sequence of callables left-to-right. Each stage receives the output of the previous one.
```php
use Tez\Utils\Fn\Pipeline;
// Immediate execution
Pipeline::process(
' Hello World ',
'trim',
'strtolower',
fn(string $s) => str_replace(' ', '-', $s),
);
// 'hello-world'
// Reusable closure
$normalize = Pipeline::build(
'trim',
'strtolower',
fn(string $s) => preg_replace('/\s+/', ' ', $s) ?? '',
);
$normalize(' Hello World '); // 'hello world'
$normalize(' FOO BAR '); // 'foo bar'
```
---
## Compose
Right-to-left composition — the mathematical counterpart to `Pipeline`. The rightmost callable is applied first.
`Compose::build(f, g, h)` is equivalent to `Pipeline::build(h, g, f)`.
```php
use Tez\Utils\Fn\Compose;
// Immediate execution — applies right-to-left: trim → strtolower → str_replace
Compose::apply(
' Hello World ',
fn(string $s) => str_replace(' ', '-', $s),
'strtolower',
'trim',
);
// 'hello-world'
// Reusable closure
$process = Compose::build(
fn(string $s) => str_replace(' ', '-', $s),
'strtolower',
'trim',
);
$process(' Hello World '); // 'hello-world'
$process(' FOO BAR '); // 'foo-bar'
```
---
## Memoize
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.
```php
use Tez\Utils\Fn\Memoize;
$expensiveCalc = Memoize::wrap(function (int $n): int {
// called only once per unique $n
return $n * $n;
});
$expensiveCalc(5); // computed: 25
$expensiveCalc(5); // from cache: 25
$expensiveCalc(6); // computed: 36
// Multiple arguments form a unique cache key
$add = Memoize::wrap(fn(int $a, int $b): int => $a + $b);
$add(1, 2); // computed: 3
$add(2, 3); // computed: 5
$add(1, 2); // from cache: 3
// Clear the cache
$expensiveCalc->flush();
$expensiveCalc(5); // computed again
```
---
## Once
Executes a callable exactly once. Every subsequent call returns the cached return value without re-invoking the callable. Each `Once` instance is independent.
```php
use Tez\Utils\Fn\Once;
$init = Once::wrap(function (): string {
// executed only on the first call
return 'initialized';
});
$init(); // 'initialized' — callable is executed
$init(); // 'initialized' — returned from cache
$init(); // 'initialized' — returned from cache
// Null return values are cached correctly
$once = Once::wrap(fn(): ?string => null);
$once(); // null — callable executed once
$once(); // null — from cache, callable NOT called again
```
---
## Partial
Pre-fills the leading arguments of a callable and returns a new `Closure` that accepts the remaining arguments.
```php
use Tez\Utils\Fn\Partial;
$multiply = fn(int $a, int $b): int => $a * $b;
$double = Partial::apply($multiply, 2);
$triple = Partial::apply($multiply, 3);
$double(5); // 10
$triple(5); // 15
// Multiple pre-filled arguments
$fn = fn(int $a, int $b, int $c): int => $a + $b + $c;
$addFive = Partial::apply($fn, 2, 3);
$addFive(10); // 15
// Works with built-in functions
$implodeWithComma = Partial::apply('implode', ', ');
$implodeWithComma(['a', 'b', 'c']); // 'a, b, c'
// Composes with Pipeline
$addPrefix = Partial::apply(fn(string $prefix, string $s) => $prefix . $s, '>>> ');
$pipeline = Pipeline::build('strtoupper', $addPrefix);
$pipeline('hello'); // '>>> HELLO'
```
---
## Retry
Retries a callable on exception. Supports linear and exponential backoff, exception allowlists (`only`), and exception blocklists (`except`). Throws the last exception when all attempts are exhausted.
```php
use Tez\Utils\Fn\Retry;
// Basic retry — up to 3 attempts (default)
$result = Retry::run(fn() => fetchFromApi());
// Custom attempt count
Retry::run(fn() => fetchFromApi(), maxAttempts: 5);
// Linear backoff — 100 ms, 200 ms, 300 ms between attempts
Retry::run(fn() => fetchFromApi(), maxAttempts: 3, backoffMs: 100);
// Exponential backoff — 100 ms, 200 ms, 400 ms between attempts
Retry::run(fn() => fetchFromApi(), maxAttempts: 4, backoffMs: 100, exponential: true);
// Only retry on specific exception types
Retry::run(
fn() => fetchFromApi(),
maxAttempts: 3,
only: [\RuntimeException::class],
);
// Never retry on specific exception types (throw immediately)
Retry::run(
fn() => fetchFromApi(),
maxAttempts: 3,
except: [\InvalidArgumentException::class],
);
```
Subclass matching is supported — `only: [\Exception::class]` retries on any `\Exception` subclass.
---
## Tap
Executes a side-effect callable on a value and returns the value unchanged. The return value of the callable is discarded.
Useful for logging, debugging, or triggering side-effects inside a pipeline without breaking the chain.
```php
use Tez\Utils\Fn\Tap;
use Tez\Utils\Fn\Pipeline;
Tap::value('hello', fn($v) => strtoupper($v)); // 'hello' — callable return discarded
// Typical use: logging inside a pipeline
$pipeline = Pipeline::build(
'trim',
fn(string $s) => Tap::value($s, fn($v) => logger()->debug('after trim', ['v' => $v])),
'strtolower',
);
```
---
## Requirements
- PHP 8.3+
- No runtime dependencies
## License
MIT
+31
View File
@@ -0,0 +1,31 @@
{
"name": "tez/utils-fn",
"type": "library",
"description": "Functional programming primitives for PHP: pipelines, composition, memoization, partial application, once-guards, retry with backoff, and tap for side-effects. PHP 8.3+, no dependencies.",
"license": "MIT",
"autoload": {
"psr-4": {
"Tez\\Utils\\Fn\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tez\\Utils\\Tests\\Fn\\": "tests/"
}
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"jetbrains/phpstorm-attributes": "^1.0",
"phpstan/phpstan": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0"
},
"scripts": {
"cs-fix": "php-cs-fixer fix",
"cs-check": "php-cs-fixer fix --dry-run --diff",
"phpstan": "phpstan analyse",
"test": "phpunit --testsuite Unit --colors=always"
}
}
+14
View File
@@ -0,0 +1,14 @@
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
- composer-cache:/tmp/composer
user: "${UID:-1000}:${GID:-1000}"
environment:
COMPOSER_HOME: /tmp/composer
volumes:
composer-cache:
+8
View File
@@ -0,0 +1,8 @@
parameters:
level: 9
paths:
- src
- tests
treatPhpDocTypesAsCertain: false
parallel:
maximumNumberOfProcesses: 1
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
+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;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Compose;
final class ComposeTest extends TestCase
{
// -------------------------------------------------------------------------
// apply()
// -------------------------------------------------------------------------
public function testApplyExecutesRightToLeft(): void
{
$result = Compose::apply(
-3,
fn(int $n) => $n * 2, // applied third: 4 * 2 = 8
fn(int $n) => $n + 1, // applied second: 3 + 1 = 4
fn(int $n) => abs($n), // applied first: abs(-3) = 3
);
self::assertSame(8, $result);
}
public function testApplyWithNoFnsReturnsValueUnchanged(): void
{
self::assertSame(42, Compose::apply(42));
}
public function testApplyWithSingleFn(): void
{
self::assertSame('HELLO', Compose::apply('hello', 'strtoupper'));
}
public function testApplyRightToLeftOrderWithStrings(): void
{
// trim first, then strtolower, then replace spaces
$result = Compose::apply(
' Hello World ',
fn(string $s) => str_replace(' ', '-', $s),
'strtolower',
'trim',
);
self::assertSame('hello-world', $result);
}
// -------------------------------------------------------------------------
// build()
// -------------------------------------------------------------------------
public function testBuildReturnsReusableClosure(): void
{
$process = Compose::build(
fn(string $s) => str_replace(' ', '-', $s),
'strtolower',
'trim',
);
self::assertSame('hello-world', $process(' Hello World '));
self::assertSame('foo-bar', $process(' FOO BAR '));
}
public function testBuildWithNoFnsReturnsValueUnchanged(): void
{
$fn = Compose::build();
self::assertSame('hello', $fn('hello'));
}
public function testBuildReturnsClosure(): void
{
self::assertInstanceOf(\Closure::class, Compose::build('trim'));
}
public function testBuildIsEquivalentToPipelineWithReversedStages(): void
{
$double = fn(int $n) => $n * 2;
$increment = fn(int $n) => $n + 1;
$absolute = fn(int $n) => abs($n);
$composed = Compose::build($double, $increment, $absolute);
self::assertSame(8, $composed(-3));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Memoize;
final class MemoizeTest extends TestCase
{
public function testCallableIsExecutedOnFirstCall(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
$calls++;
return $n * 2;
});
self::assertSame(84, $memoized(42));
self::assertSame(1, $calls);
}
public function testSameArgumentsReturnCachedValue(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
$calls++;
return $n * 2;
});
$memoized(42);
$memoized(42);
$memoized(42);
self::assertSame(1, $calls);
}
public function testDifferentArgumentsAreComputedSeparately(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
$calls++;
return $n * 2;
});
self::assertSame(84, $memoized(42));
self::assertSame(198, $memoized(99));
self::assertSame(2, $calls);
}
public function testNullReturnIsCachedCorrectly(): void
{
$calls = 0;
$memoized = Memoize::wrap(function () use (&$calls): ?string {
$calls++;
return null;
});
self::assertNull($memoized());
self::assertNull($memoized());
self::assertSame(1, $calls);
}
public function testFlushClearsCache(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
$calls++;
return $n;
});
$memoized(1);
$memoized->flush();
$memoized(1);
self::assertSame(2, $calls);
}
public function testFlushDoesNotAffectSubsequentCaching(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $n) use (&$calls): int {
$calls++;
return $n;
});
$memoized(5);
$memoized->flush();
$memoized(5);
$memoized(5);
self::assertSame(2, $calls);
}
public function testMultipleArgumentsFormUniqueKey(): void
{
$calls = 0;
$memoized = Memoize::wrap(function (int $a, int $b) use (&$calls): int {
$calls++;
return $a + $b;
});
self::assertSame(3, $memoized(1, 2));
self::assertSame(3, $memoized(1, 2));
self::assertSame(5, $memoized(2, 3));
self::assertSame(2, $calls);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Once;
final class OnceTest extends TestCase
{
public function testCallableIsExecutedOnFirstCall(): void
{
$calls = 0;
$once = Once::wrap(function () use (&$calls): int {
$calls++;
return 42;
});
$result = $once();
self::assertSame(42, $result);
self::assertSame(1, $calls);
}
public function testCallableIsNotExecutedAgainOnSubsequentCalls(): void
{
$calls = 0;
$once = Once::wrap(function () use (&$calls): int {
$calls++;
return 42;
});
$once();
$once();
$once();
self::assertSame(1, $calls);
}
public function testCachedValueIsReturnedOnSubsequentCalls(): void
{
$once = Once::wrap(fn(): string => 'hello');
self::assertSame('hello', $once());
self::assertSame('hello', $once());
}
public function testNullReturnIsCachedCorrectly(): void
{
$calls = 0;
$once = Once::wrap(function () use (&$calls): ?string {
$calls++;
return null;
});
self::assertNull($once());
self::assertNull($once());
self::assertSame(1, $calls);
}
public function testEachInstanceIsIndependent(): void
{
$calls = 0;
$fn = function () use (&$calls): int {
return ++$calls;
};
$a = Once::wrap($fn);
$b = Once::wrap($fn);
self::assertSame(1, $a());
self::assertSame(2, $b());
self::assertSame(1, $a());
self::assertSame(2, $b());
self::assertSame(2, $calls);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Partial;
final class PartialTest extends TestCase
{
public function testPrefillsSingleArgument(): void
{
$multiply = fn(int $a, int $b): int => $a * $b;
$double = Partial::apply($multiply, 2);
$triple = Partial::apply($multiply, 3);
self::assertSame(10, $double(5));
self::assertSame(15, $triple(5));
}
public function testPrefillsMultipleArguments(): void
{
$fn = fn(int $a, int $b, int $c): int => $a + $b + $c;
$addFive = Partial::apply($fn, 2, 3);
self::assertSame(15, $addFive(10));
}
public function testReturnsClosure(): void
{
$fn = Partial::apply('strtoupper');
self::assertInstanceOf(\Closure::class, $fn);
}
public function testWorksWithBuiltinFunctions(): void
{
$implodeWithComma = Partial::apply('implode', ', ');
self::assertSame('a, b, c', $implodeWithComma(['a', 'b', 'c']));
}
public function testWorksWithNoRemainingArguments(): void
{
$greet = fn(string $greeting, string $name): string => "{$greeting}, {$name}!";
$sayHello = Partial::apply($greet, 'Hello', 'World');
self::assertSame('Hello, World!', $sayHello());
}
public function testPrefillsNoArguments(): void
{
$fn = fn(int $n): int => $n * 2;
$wrapped = Partial::apply($fn);
self::assertSame(10, $wrapped(5));
}
public function testComposesWithPipeline(): void
{
$addPrefix = Partial::apply(fn(string $prefix, string $s) => $prefix . $s, '>>> ');
$pipeline = \Tez\Utils\Fn\Pipeline::build(
'strtoupper',
$addPrefix,
);
self::assertSame('>>> HELLO', $pipeline('hello'));
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Pipeline;
final class PipelineTest extends TestCase
{
// -------------------------------------------------------------------------
// process()
// -------------------------------------------------------------------------
public function testProcessPassesValueThroughStages(): void
{
$result = Pipeline::process(
' Hello World ',
'trim',
'strtolower',
fn(string $s) => str_replace(' ', '-', $s),
);
self::assertSame('hello-world', $result);
}
public function testProcessWithNoStagesReturnsPayloadUnchanged(): void
{
self::assertSame(42, Pipeline::process(42));
}
public function testProcessWithSingleStage(): void
{
self::assertSame('HELLO', Pipeline::process('hello', 'strtoupper'));
}
public function testProcessPassesReturnValueOfEachStageToNext(): void
{
$log = [];
Pipeline::process(
1,
function (int $v) use (&$log): int {
$log[] = $v;
return $v + 1;
},
function (int $v) use (&$log): int {
$log[] = $v;
return $v + 1;
},
function (int $v) use (&$log): int {
$log[] = $v;
return $v + 1;
},
);
self::assertSame([1, 2, 3], $log);
}
// -------------------------------------------------------------------------
// build()
// -------------------------------------------------------------------------
public function testBuildReturnsReusableClosure(): void
{
$normalize = Pipeline::build(
'trim',
'strtolower',
fn(string $s) => preg_replace('/\s+/', ' ', $s) ?? '',
);
self::assertSame('hello world', $normalize(' Hello World '));
self::assertSame('foo bar', $normalize(' FOO BAR '));
}
public function testBuildWithNoStagesReturnsPayloadUnchanged(): void
{
$pipe = Pipeline::build();
self::assertSame('hello', $pipe('hello'));
}
public function testBuildReturnsClosure(): void
{
self::assertInstanceOf(\Closure::class, Pipeline::build('trim'));
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Retry;
final class RetryTest extends TestCase
{
public function testSucceedsOnFirstAttempt(): void
{
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
return 'ok';
});
self::assertSame('ok', $result);
self::assertSame(1, $calls);
}
public function testRetriesAndSucceedsOnSecondAttempt(): void
{
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
if ($calls < 2) {
throw new \RuntimeException('fail');
}
return 'ok';
}, maxAttempts: 3);
self::assertSame('ok', $result);
self::assertSame(2, $calls);
}
public function testThrowsLastExceptionAfterAllAttempts(): void
{
$calls = 0;
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('attempt 3');
Retry::run(function () use (&$calls): never {
$calls++;
throw new \RuntimeException('attempt ' . $calls);
}, maxAttempts: 3);
}
public function testMaxAttemptsIsRespected(): void
{
$calls = 0;
try {
Retry::run(function () use (&$calls): never {
$calls++;
throw new \RuntimeException();
}, maxAttempts: 4);
} catch (\RuntimeException) {
}
self::assertSame(4, $calls);
}
public function testOnlyRetiesOnMatchingExceptionType(): void
{
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
if ($calls === 1) {
throw new \InvalidArgumentException('retry me');
}
return 'ok';
}, maxAttempts: 3, only: [\InvalidArgumentException::class]);
self::assertSame('ok', $result);
self::assertSame(2, $calls);
}
public function testOnlyThrowsImmediatelyForNonMatchingType(): void
{
$this->expectException(\RuntimeException::class);
Retry::run(function (): never {
throw new \RuntimeException('not in only list');
}, maxAttempts: 3, only: [\InvalidArgumentException::class]);
}
public function testExceptThrowsImmediatelyForMatchingType(): void
{
$calls = 0;
$this->expectException(\InvalidArgumentException::class);
Retry::run(function () use (&$calls): never {
$calls++;
throw new \InvalidArgumentException('stop immediately');
}, maxAttempts: 3, except: [\InvalidArgumentException::class]);
}
public function testExceptAllowsRetryForNonMatchingType(): void
{
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
if ($calls === 1) {
throw new \RuntimeException('retry ok');
}
return 'ok';
}, maxAttempts: 3, except: [\InvalidArgumentException::class]);
self::assertSame('ok', $result);
self::assertSame(2, $calls);
}
public function testBackoffWithZeroDoesNotSleep(): void
{
// Just verify it runs correctly without any sleep delay
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
if ($calls < 3) {
throw new \RuntimeException();
}
return 'done';
}, maxAttempts: 3, backoffMs: 0);
self::assertSame('done', $result);
}
public function testSubclassMatchesOnlyFilter(): void
{
$calls = 0;
$result = Retry::run(function () use (&$calls): string {
$calls++;
if ($calls === 1) {
// LogicException extends \Exception which extends \Throwable
throw new \LogicException('subclass');
}
return 'ok';
}, maxAttempts: 3, only: [\Exception::class]);
self::assertSame('ok', $result);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Tests\Fn;
use PHPUnit\Framework\TestCase;
use Tez\Utils\Fn\Tap;
final class TapTest extends TestCase
{
public function testReturnsOriginalValue(): void
{
$result = Tap::value('hello', fn($v) => strtoupper($v));
self::assertSame('hello', $result);
}
public function testCallableSideEffectIsExecuted(): void
{
$called = false;
Tap::value('anything', function () use (&$called): void {
$called = true;
});
self::assertTrue($called);
}
public function testCallableReceivesTheValue(): void
{
$received = null;
Tap::value(42, function (int $v) use (&$received): void {
$received = $v;
});
self::assertSame(42, $received);
}
public function testReturnValueOfCallableIsDiscarded(): void
{
$result = Tap::value('original', fn() => 'ignored');
self::assertSame('original', $result);
}
public function testWorksWithObjects(): void
{
$obj = new \stdClass();
$result = Tap::value($obj, fn($v) => null);
self::assertSame($obj, $result);
}
public function testWorksWithNull(): void
{
$result = Tap::value(null, fn($v) => 'ignored');
self::assertNull($result);
}
}