diff --git a/src/support/src/Contracts/HasOnceHash.php b/src/support/src/Contracts/HasOnceHash.php new file mode 100644 index 000000000..b236c9362 --- /dev/null +++ b/src/support/src/Contracts/HasOnceHash.php @@ -0,0 +1,13 @@ +> $values + */ + protected function __construct(protected WeakMap $values) + { + } + + /** + * Create a new once instance. + */ + public static function instance(): static + { + return Context::getOrSet(self::INSTANCE_CONTEXT_KEY, fn () => new static(new WeakMap())); + } + + /** + * Get the value of the given onceable. + */ + public function value(Onceable $onceable): mixed + { + if (Context::get(self::ENABLED_CONTEXT_KEY, true) !== true) { + return call_user_func($onceable->callable); + } + + $object = $onceable->object ?: $this; + + $hash = $onceable->hash; + + if (! isset($this->values[$object])) { + $this->values[$object] = []; + } + + if (array_key_exists($hash, $this->values[$object])) { + return $this->values[$object][$hash]; + } + + return $this->values[$object][$hash] = call_user_func($onceable->callable); + } + + /** + * Re-enable the once instance if it was disabled. + */ + public static function enable(): void + { + Context::set(self::ENABLED_CONTEXT_KEY, true); + } + + /** + * Disable the once instance. + */ + public static function disable(): void + { + Context::set(self::ENABLED_CONTEXT_KEY, false); + } + + /** + * Flush the once instance. + */ + public static function flush(): void + { + Context::destroy(self::INSTANCE_CONTEXT_KEY); + } +} diff --git a/src/support/src/Onceable.php b/src/support/src/Onceable.php new file mode 100644 index 000000000..d270d8e93 --- /dev/null +++ b/src/support/src/Onceable.php @@ -0,0 +1,92 @@ +> $trace + */ + public static function tryFromTrace(array $trace, callable $callable): ?static + { + if (! is_null($hash = static::hashFromTrace($trace, $callable))) { + $object = static::objectFromTrace($trace); + + return new static($hash, $object, $callable); + } + + return null; + } + + /** + * Computes the object of the onceable from the given trace, if any. + * + * @param array> $trace + * @return null|object + */ + protected static function objectFromTrace(array $trace) + { + return $trace[1]['object'] ?? null; + } + + /** + * Computes the hash of the onceable from the given trace. + * + * @param array> $trace + * @return null|string + */ + protected static function hashFromTrace(array $trace, callable $callable) + { + if (str_contains($trace[0]['file'] ?? '', 'eval()\'d code')) { + return null; + } + + $uses = array_map( + static function (mixed $argument) { + if ($argument instanceof HasOnceHash) { + return $argument->onceHash(); + } + + if (is_object($argument)) { + return spl_object_hash($argument); + } + + return $argument; + }, + $callable instanceof Closure ? (new ReflectionClosure($callable))->getClosureUsedVariables() : [], + ); + + $class = $callable instanceof Closure ? (new ReflectionClosure($callable))->getClosureCalledClass()?->getName() : null; + + $class ??= isset($trace[1]['class']) ? $trace[1]['class'] : null; + + return hash('xxh128', sprintf( + '%s@%s%s:%s (%s)', + $trace[0]['file'], + $class ? $class . '@' : '', + $trace[1]['function'], + $trace[0]['line'], + serialize($uses), + )); + } +} diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index 1545b5b3c..5651de293 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -8,6 +8,8 @@ use Hypervel\Support\Contracts\Htmlable; use Hypervel\Support\Environment; use Hypervel\Support\HigherOrderTapProxy; +use Hypervel\Support\Once; +use Hypervel\Support\Onceable; use Hypervel\Support\Sleep; if (! function_exists('value')) { @@ -219,6 +221,26 @@ function object_get(object $object, ?string $key, mixed $default = null): mixed } } +if (! function_exists('once')) { + /** + * Ensures a callable is only called once, and returns the result on subsequent calls. + * + * @template TReturnType + * + * @param callable(): TReturnType $callback + * @return TReturnType + */ + function once(callable $callback) + { + $onceable = Onceable::tryFromTrace( + debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2), + $callback, + ); + + return $onceable ? Once::instance()->value($onceable) : call_user_func($callback); + } +} + if (! function_exists('optional')) { /** * Provide access to optional objects. diff --git a/tests/Support/OnceTest.php b/tests/Support/OnceTest.php new file mode 100644 index 000000000..3f7a4ffda --- /dev/null +++ b/tests/Support/OnceTest.php @@ -0,0 +1,88 @@ +newCounter(); + + $first = $this->runOnceWithCounter($counter); + $second = $this->runOnceWithCounter($counter); + + $this->assertSame(1, $first); + $this->assertSame(1, $second); + $this->assertSame(1, $counter->value); + } + + public function testOnceDifferentiatesClosureUses(): void + { + $results = array_map( + fn (int $value) => once(fn () => $value), + [1, 2], + ); + + $this->assertSame([1, 2], $results); + } + + public function testOnceIsCoroutineScoped(): void + { + $counter = $this->newCounter(); + $results = []; + + run(function () use (&$results, $counter): void { + $results = parallel([ + fn () => $this->runOnceWithCounter($counter), + fn () => $this->runOnceWithCounter($counter), + ]); + }); + + sort($results); + + $this->assertSame([1, 2], $results); + $this->assertSame(2, $counter->value); + } + + private function newCounter(): object + { + return new class { + public int $value = 0; + }; + } + + private function runOnceWithCounter(object $counter): int + { + return once(function () use ($counter): int { + return ++$counter->value; + }); + } +} diff --git a/tests/Support/OnceableTest.php b/tests/Support/OnceableTest.php new file mode 100644 index 000000000..3adf6830d --- /dev/null +++ b/tests/Support/OnceableTest.php @@ -0,0 +1,66 @@ +createOnceable(fn () => 'value'); + + $this->assertSame($this, $onceable->object); + } + + public function testHashUsesOnceHashImplementation(): void + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + + $value = new OnceHashStub('same'); + $onceableA = Onceable::tryFromTrace($trace, fn () => $value); + + $value = new OnceHashStub('same'); + $onceableB = Onceable::tryFromTrace($trace, fn () => $value); + + $value = new OnceHashStub('different'); + $onceableC = Onceable::tryFromTrace($trace, fn () => $value); + + $this->assertNotNull($onceableA); + $this->assertNotNull($onceableB); + $this->assertNotNull($onceableC); + $this->assertSame($onceableA->hash, $onceableB->hash); + $this->assertNotSame($onceableA->hash, $onceableC->hash); + } + + private function createOnceable(callable $callback): Onceable + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + + $onceable = Onceable::tryFromTrace($trace, $callback); + + $this->assertNotNull($onceable); + + return $onceable; + } +} + +class OnceHashStub implements HasOnceHash +{ + public function __construct(private string $hash) + { + } + + public function onceHash(): string + { + return $this->hash; + } +}