This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/vendor/
|
||||||
|
/.php-cs-fixer.cache
|
||||||
|
/.phpunit.result.cache
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
parameters:
|
||||||
|
level: 9
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
|
treatPhpDocTypesAsCertain: false
|
||||||
|
parallel:
|
||||||
|
maximumNumberOfProcesses: 1
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user