From 3314652b18524b584d636d32499c8af3f43b8c0b Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Thu, 1 Oct 2026 19:18:03 +0600 Subject: [PATCH] Add blank, filled, with, rescue, retry and benchmark helpers --- src/Phaseolies/Helpers/helpers.php | 154 +++++++++++++++++++++++++++++ tests/Helpers/ValueHelpersTest.php | 146 +++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 tests/Helpers/ValueHelpersTest.php diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index e8798f14..0a90d45e 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -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 diff --git a/tests/Helpers/ValueHelpersTest.php b/tests/Helpers/ValueHelpersTest.php new file mode 100644 index 00000000..cc6fc623 --- /dev/null +++ b/tests/Helpers/ValueHelpersTest.php @@ -0,0 +1,146 @@ +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']); + } +}