Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/Phaseolies/Helpers/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,160 @@ public function get()
}
}

if (!function_exists('blank')) {
/**
* Determine if the given value is "blank"
*
* @param mixed $value
* @return bool
*/
function blank(mixed $value): bool
{
if (is_null($value)) {
return true;
}

if (is_string($value)) {
return trim($value) === '';
}

if (is_numeric($value) || is_bool($value)) {
return false;
}

if ($value instanceof Countable) {
return count($value) === 0;
}

if (is_array($value)) {
return $value === [];
}

return false;
}
}

if (!function_exists('filled')) {
/**
* Determine if the given value is not "blank"
*
* @param mixed $value
* @return bool
*/
function filled(mixed $value): bool
{
return !blank($value);
}
}

if (!function_exists('with')) {
/**
* Pass a value through a callback and return the result
*
* @param mixed $value
* @param callable|null $callback
* @return mixed
*/
function with(mixed $value, ?callable $callback = null): mixed
{
return is_null($callback) ? $value : $callback($value);
}
}

if (!function_exists('rescue')) {
/**
* Run a callback and return a default value if it throws
*
* @param callable $callback
* @param mixed $default
* @param bool $report
* @return mixed
*/
function rescue(callable $callback, mixed $default = null, bool $report = true): mixed
{
try {
return $callback();
} catch (Throwable $e) {
if ($report) {
try {
Log::error($e->getMessage(), ['exception' => $e]);
} catch (Throwable) {
// A broken logger must not hide the fallback value
}
}

return $default instanceof Closure ? $default($e) : $default;
}
}
}

if (!function_exists('retry')) {
/**
* Run a callback up to $times attempts, retrying when it throws
*
* @param int $times
* @param callable $callback
* @param int|Closure $sleepMs
* @param callable|null $when
* @return mixed
* @throws InvalidArgumentException
* @throws Throwable
*/
function retry(int $times, callable $callback, int|Closure $sleepMs = 0, ?callable $when = null): mixed
{
if ($times < 1) {
throw new InvalidArgumentException('retry() requires at least one attempt.');
}

$attempt = 0;

while (true) {
$attempt++;

try {
return $callback($attempt);
} catch (Throwable $e) {
if ($attempt >= $times || ($when !== null && !$when($e))) {
throw $e;
}

$delay = $sleepMs instanceof Closure ? $sleepMs($attempt) : $sleepMs;

if ($delay > 0) {
usleep($delay * 1000);
}
}
}
}
}

if (!function_exists('benchmark')) {
/**
* Measure the execution time and memory use of a callback
*
* @param callable $callback
* @return array{result: mixed, time_ms: float, memory_bytes: int, peak_memory_bytes: int}
*/
function benchmark(callable $callback): array
{
memory_reset_peak_usage();

$memoryBefore = memory_get_usage();
$start = hrtime(true);

$result = $callback();

$time = (hrtime(true) - $start) / 1e6;

return [
'result' => $result,
'time_ms' => $time,
'memory_bytes' => memory_get_usage() - $memoryBefore,
'peak_memory_bytes' => max(0, memory_get_peak_usage() - $memoryBefore),
];
}
}

if (!function_exists('ddd')) {
/**
* Clean previous buffer and die
Expand Down
146 changes: 146 additions & 0 deletions tests/Helpers/ValueHelpersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace Tests\Unit\Helpers;

use ArrayObject;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use RuntimeException;

class ValueHelpersTest extends TestCase
{
public function testBlankAndFilled(): void
{
foreach ([null, '', ' ', "\n\t", [], new ArrayObject()] as $value) {
$this->assertTrue(blank($value));
$this->assertFalse(filled($value));
}

foreach ([0, 0.0, '0', false, true, 'a', [0], new ArrayObject([1]), new \stdClass()] as $value) {
$this->assertFalse(blank($value));
$this->assertTrue(filled($value));
}
}

public function testWith(): void
{
$this->assertSame(10, with(5, fn($v) => $v * 2));
$this->assertSame(5, with(5));
}

public function testRescueReturnsResultWhenNothingThrows(): void
{
$this->assertSame('ok', rescue(fn() => 'ok', 'fallback'));
}

public function testRescueReturnsDefaultOnException(): void
{
$this->assertSame('fallback', rescue(fn() => throw new RuntimeException('x'), 'fallback', false));
$this->assertNull(rescue(fn() => throw new RuntimeException('x'), report: false));
}

public function testRescueDefaultClosureReceivesException(): void
{
$result = rescue(
fn() => throw new RuntimeException('boom'),
fn($e) => strtoupper($e->getMessage()),
false
);

$this->assertSame('BOOM', $result);
}

public function testRetrySucceedsAfterFailures(): void
{
$attempts = [];

$result = retry(3, function ($attempt) use (&$attempts) {
$attempts[] = $attempt;

if ($attempt < 3) {
throw new RuntimeException('fail');
}

return 'done';
});

$this->assertSame('done', $result);
$this->assertSame([1, 2, 3], $attempts);
}

public function testRetryRethrowsLastExceptionWhenExhausted(): void
{
$calls = 0;

try {
retry(2, function () use (&$calls) {
throw new RuntimeException('attempt ' . ++$calls);
});
$this->fail('Expected exception');
} catch (RuntimeException $e) {
$this->assertSame('attempt 2', $e->getMessage());
$this->assertSame(2, $calls);
}
}

public function testRetryStopsImmediatelyWhenWhenReturnsFalse(): void
{
$calls = 0;

$this->expectException(RuntimeException::class);

try {
retry(5, function () use (&$calls) {
$calls++;
throw new RuntimeException('fatal');
}, 0, fn($e) => false);
} finally {
$this->assertSame(1, $calls);
}
}

public function testRetrySleepsBetweenAttempts(): void
{
$delays = [];

retry(3, function ($attempt) {
if ($attempt < 3) {
throw new RuntimeException('x');
}
}, function ($attempt) use (&$delays) {
$delays[] = $attempt;

return 1;
});

$this->assertSame([1, 2], $delays);

$start = hrtime(true);
try {
retry(2, fn() => throw new RuntimeException('x'), 30);
} catch (RuntimeException) {
}
$this->assertGreaterThanOrEqual(25, (hrtime(true) - $start) / 1e6);
}

public function testRetryRequiresAtLeastOneAttempt(): void
{
$this->expectException(InvalidArgumentException::class);

retry(0, fn() => 1);
}

public function testBenchmarkReturnsResultTimeAndMemory(): void
{
$report = benchmark(function () {
usleep(20000);

return str_repeat('a', 1024 * 1024);
});

$this->assertSame(1024 * 1024, strlen($report['result']));
$this->assertGreaterThanOrEqual(15.0, $report['time_ms']);
$this->assertGreaterThanOrEqual(1024 * 1024, $report['peak_memory_bytes']);
$this->assertIsInt($report['memory_bytes']);
}
}
Loading