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