diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index 634a19542203..1d3afde7052e 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -792,17 +792,21 @@ public function touches($relation) */ public function touchOwners() { - foreach ($this->getTouchedRelations() as $relation) { - $this->$relation()->touch(); - - if ($this->$relation instanceof self) { - $this->$relation->fireModelEvent('saved', false); - - $this->$relation->touchOwners(); - } elseif ($this->$relation instanceof Collection) { - $this->$relation->each->touchOwners(); - } - } + without_recursion( + function () { + foreach ($this->getTouchedRelations() as $relation) { + $this->$relation()->touch(); + + if ($this->$relation instanceof self) { + $this->$relation->fireModelEvent('saved', false); + + $this->$relation->touchOwners(); + } elseif ($this->$relation instanceof Collection) { + $this->$relation->each->touchOwners(); + } + } + }, + ); } /** diff --git a/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php index 27d69a98b1ae..ad4fdadf8fde 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php +++ b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php @@ -21,6 +21,8 @@ trait PreventsCircularRecursion * @param callable $callback * @param mixed $default * @return mixed + * + * @deprecated Replaced by `without_recursion()` helper */ protected function withoutRecursion($callback, $default = null) { diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 7afa59933416..2f3ca7d6a875 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -1084,7 +1084,7 @@ protected function decrementQuietly($column, $amount = 1, array $extra = []) */ public function push() { - return $this->withoutRecursion(function () { + return without_recursion(function () { if (! $this->save()) { return false; } @@ -1660,7 +1660,7 @@ public function callNamedScope($scope, array $parameters = []) */ public function toArray() { - return $this->withoutRecursion( + return without_recursion( fn () => array_merge($this->attributesToArray(), $this->relationsToArray()), fn () => $this->attributesToArray(), ); @@ -2010,7 +2010,7 @@ public function getQueueableId() */ public function getQueueableRelations() { - return $this->withoutRecursion(function () { + return without_recursion(function () { $relations = []; foreach ($this->getRelations() as $key => $relation) { diff --git a/src/Illuminate/Support/Exceptions/RecursableNotFoundException.php b/src/Illuminate/Support/Exceptions/RecursableNotFoundException.php new file mode 100644 index 000000000000..aa6a8c4b1f4e --- /dev/null +++ b/src/Illuminate/Support/Exceptions/RecursableNotFoundException.php @@ -0,0 +1,20 @@ +signature ?: implode('@', [ + $recursable->object ? get_class($recursable->object) : 'global', + $recursable->hash, + ]), + )); + } +} diff --git a/src/Illuminate/Support/Recursable.php b/src/Illuminate/Support/Recursable.php new file mode 100644 index 000000000000..ca8d3f992f9c --- /dev/null +++ b/src/Illuminate/Support/Recursable.php @@ -0,0 +1,184 @@ +signature = $signature ?: static::BLANK_SIGNATURE; + $this->hash = $hash ?: static::hashFromSignature($this->signature); + } + + /** + * Read-only access to the properties of the recursable. + * + * @param string $name + * @return mixed + */ + public function __get(string $name): mixed + { + return property_exists($this, $name) ? $this->{$name} : null; + } + + /** + * Set the object of the recursable if it is not already set. + * + * @param object $object + * @return $this + */ + public function for(object $object): static + { + $this->object ??= $object; + + return $this; + } + + /** + * Set the value to return when recursing. + * + * @param TRecursionType $value + * @return $this + */ + public function return(mixed $value): static + { + $this->onRecursion = $value; + + return $this; + } + + /** + * Creates a new recursable instance from the given trace. + * + * @param array> $trace + * @param callable(): TReturnType $callback + * @param TRecursionType $onRecursion + * @param object|null $object + * @return static + */ + public static function fromTrace( + array $trace, + callable $callback, + mixed $onRecursion, + ?object $object = null, + ): static { + return new static( + $callback, + $onRecursion, + $object ?? static::objectFromTrace($trace), + static::signatureFromTrace($trace), + static::hashFromTrace($trace), + ); + } + + /** + * Creates a new recursable instance from a given signature. + * + * @param string $signature + * @param callable(): TReturnType $callback + * @param TRecursionType $onRecursion + * @param object|null $object + * @return static + */ + public static function fromSignature( + string $signature, + callable $callback, + mixed $onRecursion, + ?object $object = null, + ): static { + return new static( + $callback, + $onRecursion, + $object, + $signature, + static::hashFromSignature($signature ?: static::BLANK_SIGNATURE), + ); + } + + /** + * Computes the target method from the given trace, if any. + * + * @param array> $trace + * @return array{file: string, class: string, function: string, line: int, object: object|null} + */ + protected static function targetFromTrace(array $trace): array + { + return [ + 'file' => $trace[0]['file'] ?? '', + 'class' => $trace[1]['class'] ?? '', + 'function' => $trace[1]['function'] ?? '', + 'line' => $trace[0]['line'] ?? 0, + 'object' => $trace[1]['object'] ?? null, + ]; + } + + /** + * Computes the object of the recursable from the given trace, if any. + * + * @param array> $trace + * @return object|null + */ + protected static function objectFromTrace(array $trace): object|null + { + return static::targetFromTrace($trace)['object']; + } + + /** + * Computes the signature of the recursable. + * + * @param array> $trace + * @return string + */ + protected static function signatureFromTrace(array $trace): string + { + $target = static::targetFromTrace($trace); + + return sprintf( + '%s:%s%s', + $target['file'], + $target['class'] ? ($target['class'].'@') : '', + $target['function'] ?: $target['line'], + ); + } + + /** + * Computes the hash of the recursable from the given trace. + * + * @param array> $trace + * @return string + */ + protected static function hashFromTrace(array $trace): string + { + return static::hashFromSignature(static::signatureFromTrace($trace)); + } + + /** + * Computes the hash of the recursable from the given signature. + * + * @param string $signature + * @return string + */ + protected static function hashFromSignature(string $signature): string + { + return hash('xxh128', $signature); + } +} diff --git a/src/Illuminate/Support/Recurser.php b/src/Illuminate/Support/Recurser.php new file mode 100644 index 000000000000..4069645baff2 --- /dev/null +++ b/src/Illuminate/Support/Recurser.php @@ -0,0 +1,169 @@ +> $cache + * @return void + */ + public function __construct(protected WeakMap $cache) + { + $this->globalContext = (object) []; + } + + /** + * Get or create the current globally used instance. + * + * @return static + */ + public static function instance(): static + { + return static::$instance ??= new static(new WeakMap); + } + + /** + * Flush the recursion cache. + * + * @return void + */ + public static function flush(): void + { + static::$instance = null; + } + + /** + * Prevent a method from being called multiple times on the same instance of an object within the same call stack. + * + * @param Recursable $target + * @return mixed + */ + public function withoutRecursion(Recursable $target): mixed + { + $target->for($this->globalContext); + + if ($this->hasValue($target)) { + return $this->getRecursedValue($target); + } + + try { + $this->setRecursedValue($target); + + return call_user_func($target->callback); + } finally { + $this->release($target); + } + } + + /** + * Get the stack of methods being called recursively for the given object. + * + * @param object $instance + * @return array + */ + protected function getStack(object $instance): array + { + return $this->cache->offsetExists($instance) ? $this->cache->offsetGet($instance) : []; + } + + /** + * Set the stack of methods being called recursively for the given object. + * + * @param object $instance + * @param array $stack + */ + protected function setStack(object $instance, array $stack): void + { + if ($stack) { + $this->cache->offsetSet($instance, $stack); + } elseif ($this->cache->offsetExists($instance)) { + $this->cache->offsetUnset($instance); + } + } + + /** + * Check if there is a stored value for the recursable target. + * + * @param Recursable $target + * @return bool + */ + protected function hasValue(Recursable $target): bool + { + return array_key_exists($target->hash, $this->getStack($target->object)); + } + + /** + * Get the currently stored value of the given recursable. + * + * @param Recursable $target + * @return mixed + */ + protected function getRecursedValue(Recursable $target): mixed + { + if ($this->hasValue($target)) { + return with( + $this->getStack($target->object)[$target->hash], + function ($value) use ($target) { + if (is_callable($value)) { + $target->return(call_user_func($value)); + + return $this->setRecursedValue($target); + } + + return $value; + } + ); + } + + throw RecursableNotFoundException::make($target); + } + + /** + * Set the currently stored value of the given recursable. + * + * @param Recursable $target + * @return mixed + */ + protected function setRecursedValue(Recursable $target): mixed + { + $stack = tap( + $this->getStack($target->object), + fn (array &$stack) => $stack[$target->hash] = $target->onRecursion, + ); + + $this->setStack($target->object, $stack); + + return $stack[$target->hash]; + } + + /** + * Release a recursable from the stack. + * + * @param Recursable $target + * @return void + */ + protected function release(Recursable $target): void + { + $this->setStack($target->object, Arr::except($this->getStack($target->object), $target->hash)); + } +} diff --git a/src/Illuminate/Support/helpers.php b/src/Illuminate/Support/helpers.php index d46bf6ffcfd3..b5ed763ea474 100644 --- a/src/Illuminate/Support/helpers.php +++ b/src/Illuminate/Support/helpers.php @@ -9,6 +9,8 @@ use Illuminate\Support\Once; use Illuminate\Support\Onceable; use Illuminate\Support\Optional; +use Illuminate\Support\Recursable; +use Illuminate\Support\Recurser; use Illuminate\Support\Sleep; use Illuminate\Support\Str; @@ -507,3 +509,28 @@ function with($value, ?callable $callback = null) return is_null($callback) ? $value : $callback($value); } } + +if (! function_exists('without_recursion')) { + /** + * Executes a callback and returns a secondary value for recursive calls. + * + * @template TReturnType + * @template TRecursionCallable of callable(): TReturnType + * @template TRecursionType of TReturnType|TRecursionCallable + * + * @param callable(): TReturnType $callback + * @param TRecursionType $onRecursion + * @param string|null $as + * @param object|null $for + * @return TReturnType + */ + function without_recursion(callable $callback, mixed $onRecursion = null, ?string $as = null, ?object $for = null): mixed + { + return Recurser::instance() + ->withoutRecursion( + $as + ? Recursable::fromSignature($as, $callback, $onRecursion, $for) + : Recursable::fromTrace(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2), $callback, $onRecursion, $for) + ); + } +} diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 3dbb553891c9..26541e631c3b 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -152,6 +152,13 @@ protected function createSchema() $table->morphs('taggable'); $table->string('taxonomy')->nullable(); }); + + $this->schema($connection)->create('categories', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->integer('parent_id')->nullable(); + $table->timestamps(); + }); } $this->schema($connection)->create('non_incrementing_users', function ($table) { @@ -2182,6 +2189,61 @@ public function testMorphPivotsCanBeRefreshed() $this->assertSame('primary', $pivot->taxonomy); } + public function testTouchingChaperonedChildModelUpdatesParentTimestamps() + { + $before = Carbon::now(); + + $one = EloquentTouchingCategory::create(['id' => 1, 'name' => 'One']); + $two = $one->children()->create(['id' => 2, 'name' => 'Two']); + + $this->assertTrue($before->isSameDay($one->updated_at)); + $this->assertTrue($before->isSameDay($two->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + $two->touch(); + + $this->assertTrue($future->isSameDay($two->fresh()->updated_at), 'It is not touching model own timestamps.'); + $this->assertTrue($future->isSameDay($one->fresh()->updated_at), 'It is not touching chaperoned models related timestamps.'); + } + + public function testTouchingBiDirectionalChaperonedModelUpdatesAllRelatedTimestamps() + { + $before = Carbon::now(); + + EloquentTouchingCategory::insert([ + ['id' => 1, 'name' => 'One', 'parent_id' => null, 'created_at' => $before, 'updated_at' => $before], + ['id' => 2, 'name' => 'Two', 'parent_id' => 1, 'created_at' => $before, 'updated_at' => $before], + ['id' => 3, 'name' => 'Three', 'parent_id' => 1, 'created_at' => $before, 'updated_at' => $before], + ['id' => 4, 'name' => 'Four', 'parent_id' => 2, 'created_at' => $before, 'updated_at' => $before], + ]); + + $one = EloquentTouchingCategory::find(1); + [$two, $three] = $one->children; + [$four] = $two->children; + + $this->assertTrue($before->isSameDay($one->updated_at)); + $this->assertTrue($before->isSameDay($two->updated_at)); + $this->assertTrue($before->isSameDay($three->updated_at)); + $this->assertTrue($before->isSameDay($four->updated_at)); + + Carbon::setTestNow($future = $before->copy()->addDays(3)); + + // Touch a random model and check that all of the others have been updated + $models = tap([$one, $two, $three, $four], shuffle(...)); + $target = array_shift($models); + $target->touch(); + + $this->assertTrue($future->isSameDay($target->fresh()->updated_at), 'It is not touching model own timestamps.'); + + while ($next = array_shift($models)) { + $this->assertTrue( + $future->isSameDay($next->fresh()->updated_at), + 'It is not touching related models timestamps.' + ); + } + } + /** * Helpers... */ @@ -2486,3 +2548,24 @@ public function post() return $this->belongsTo(EloquentTouchingPost::class, 'post_id'); } } + +class EloquentTouchingCategory extends Eloquent +{ + protected $table = 'categories'; + protected $guarded = []; + + protected $touches = [ + 'parent', + 'children', + ]; + + public function parent() + { + return $this->belongsTo(EloquentTouchingCategory::class, 'parent_id'); + } + + public function children() + { + return $this->hasMany(EloquentTouchingCategory::class, 'parent_id')->chaperone(); + } +} diff --git a/tests/Integration/Support/WithoutRecursionHelperTest.php b/tests/Integration/Support/WithoutRecursionHelperTest.php new file mode 100644 index 000000000000..894e8c706f74 --- /dev/null +++ b/tests/Integration/Support/WithoutRecursionHelperTest.php @@ -0,0 +1,495 @@ +assertSame([1], $instance->downline()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([1], $instance->upline()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([], $instance->children()); + $this->assertSame([], $instance->pullCallCount()); + + $this->assertSame([], $instance->ancestors()); + $this->assertSame([], $instance->pullCallCount()); + } + + public function testRecursionCallbacksAreNotCalledOnNonRecursiveSingleInstances() + { + $instance = new DoublyLinkedRecursiveList(1); + + $this->assertSame($instance, $instance->head()); + $this->assertSame(['head' => 1, 'head_callback' => 1], $instance->pullCallCount()); + + $this->assertSame($instance, $instance->tail()); + $this->assertSame(['tail' => 1, 'tail_callback' => 1], $instance->pullCallCount()); + } + + public function testCallbacksAreCalledOnNonRecursiveInstances() + { + $instance = DoublyLinkedRecursiveList::make(children: 3); + + $this->assertSame([1, 2, 3, 4], $instance->downline()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([1], $instance->upline()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([2, 3, 4], $instance->children()); + $this->assertSame([], $instance->pullCallCount()); + + $this->assertSame([], $instance->ancestors()); + $this->assertSame([], $instance->pullCallCount()); + } + + public function testRecursionCallbacksAreNotCalledOnNonRecursiveInstances() + { + $instance = DoublyLinkedRecursiveList::make(children: 3); + + $this->assertSame($instance, $instance->head()); + $this->assertSame(['head' => 1, 'head_callback' => 1], $instance->pullCallCount()); + + $this->assertNotSame($instance, $instance->tail()); + $this->assertSame(['tail' => 1, 'tail_callback' => 1], $instance->pullCallCount()); + } + + public function testCallbacksAreCalledOnceOnRecursiveInstances() + { + $head = DoublyLinkedRecursiveList::make(children: 3); + $tail = $head->tail(); + + $head->resetCallCount(); + $tail->resetCallCount(); + + // Make the list circular + $head->setPrev($tail); + + $this->assertSame([1, 2, 3, 4], $head->downline()); + $this->assertSame(['downline' => 2, 'downline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([1, 4, 3, 2], $head->upline()); + $this->assertSame(['upline' => 2, 'upline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([2, 3, 4, 1], $head->children()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([4, 3, 2, 1], $head->ancestors()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['upline' => 2, 'upline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([4, 1, 2, 3], $tail->downline()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['downline' => 2, 'downline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([4, 3, 2, 1], $tail->upline()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['upline' => 2, 'upline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([1, 2, 3, 4], $tail->children()); + $this->assertSame(['downline' => 2, 'downline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['downline' => 1, 'downline_callback' => 1], $tail->pullCallCount()); + + $this->assertSame([3, 2, 1, 4], $tail->ancestors()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $head->pullCallCount()); + $this->assertSame(['upline' => 1, 'upline_callback' => 1], $tail->pullCallCount()); + } + + public function testRecursionCallbacksAreCalledOnRecursiveInstances() + { + $head = DoublyLinkedRecursiveList::make(children: 2); + $body = $head->getNext(); + $tail = $body->getNext(); + + // Make the list circular + $head->setPrev($tail); + + $this->assertSame($body, $head->head()); + $this->assertSame(['head' => 2, 'head_callback' => 1, 'head_recursive_callback' => 1], $head->pullCallCount()); + $this->assertSame(['head' => 1, 'head_callback' => 1], $tail->pullCallCount()); + + $this->assertSame($tail, $head->tail()); + $this->assertSame(['tail' => 2, 'tail_callback' => 1, 'tail_recursive_callback' => 1], $head->pullCallCount()); + $this->assertSame(['tail' => 1, 'tail_callback' => 1], $tail->pullCallCount()); + + $this->assertSame($head, $tail->head()); + $this->assertSame(['head' => 1, 'head_callback' => 1], $head->pullCallCount()); + $this->assertSame(['head' => 2, 'head_callback' => 1, 'head_recursive_callback' => 1], $tail->pullCallCount()); + + $this->assertSame($body, $tail->tail()); + $this->assertSame(['tail' => 1, 'tail_callback' => 1], $head->pullCallCount()); + $this->assertSame(['tail' => 2, 'tail_callback' => 1, 'tail_recursive_callback' => 1], $tail->pullCallCount()); + } + + public function testCallbacksAreCalledOnceOnSelfReferentialInstances() + { + $instance = tap(new DoublyLinkedRecursiveList(1), fn ($list) => $list->setNext($list)); + + $this->assertSame([1], $instance->downline()); + $this->assertSame(['downline' => 2, 'downline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([1], $instance->upline()); + $this->assertSame(['upline' => 2, 'upline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([1], $instance->children()); + $this->assertSame(['downline' => 2, 'downline_callback' => 1], $instance->pullCallCount()); + + $this->assertSame([1], $instance->ancestors()); + $this->assertSame(['upline' => 2, 'upline_callback' => 1], $instance->pullCallCount()); + } + + public function testRecursionCallbacksAreCalledOnceOnSelfReferentialInstances() + { + $instance = tap(new DoublyLinkedRecursiveList(1), fn ($list) => $list->setNext($list)); + + $this->assertSame($instance, $instance->head()); + $this->assertSame(['head' => 2, 'head_callback' => 1, 'head_recursive_callback' => 1], $instance->pullCallCount()); + + $this->assertSame($instance, $instance->tail()); + $this->assertSame(['tail' => 2, 'tail_callback' => 1, 'tail_recursive_callback' => 1], $instance->pullCallCount()); + } + + public function testWithoutRecursionWorksClosure() + { + $foo = function ($depth) use (&$foo) { + return without_recursion(fn () => $foo($depth) + 1, $depth); + }; + + $this->assertSame(1, $foo(0)); + $this->assertSame(2, $foo($foo(0))); + } + + public function testWithoutRecursionWorksInGlobalFunction() + { + $result = recursive_rand(); + $this->assertMatchesRegularExpression('/^\d+:\d+$/', $result); + } + + public function testWithoutRecursionWorksInInvokableClass() + { + $foo = new InvokableRecursiveRepeater('foo'); + + $this->assertSame('foo', $foo()); + $this->assertSame('foo:foo:foo', $foo(3)); + + $bar = new InvokableRecursiveRepeater($foo); + + $this->assertSame('', $bar(0)); + $this->assertSame('foo:foo:foo:foo:foo', $bar(5)); + } + + public function testWithoutRecursionOnlyCallsRecursionCallbackOncePerCallStack() + { + $counter = 0; + + $onRecursion = function () use (&$counter) { + return ++$counter; + }; + + $callback = function (int $times) use (&$callback, $onRecursion) { + return without_recursion(function () use ($callback, $times) { + $values = []; + + for ($i = 0; $i < $times; $i++) { + $values[] = $callback($times - 1); + } + + return implode(':', $values); + }, $onRecursion); + }; + + $this->assertSame('1:1:1', $callback(3)); + $this->assertSame(1, $counter); + $this->assertSame('2:2:2:2:2', $callback(5)); + $this->assertSame(2, $counter); + } + + public function testWithoutRecursionAllowsObjectOverrideWithTrace() + { + $recurser = MockRecurser::mock(); + + $signature = sprintf('%s:%s@%s', __FILE__, __CLASS__, __FUNCTION__); + $object = (object) []; + + $recurser->shouldReceive('withoutRecursion') + ->once() + ->withArgs(function (Recursable $target) use ($signature) { + return + $target->signature === $signature + && $target->object === $this + && $target->hash === hash('xxh128', $signature); + }); + + without_recursion(fn () => null, null); + + $recurser->shouldReceive('withoutRecursion') + ->once() + ->withArgs(function (Recursable $target) use ($signature, $object) { + return + $target->signature === $signature + && $target->object === $object + && $target->hash === hash('xxh128', $signature); + }); + + without_recursion(fn () => null, null, for: $object); + } + + public function testWithoutRecursionUsesSignatureInsteadOfTrace() + { + $recurser = MockRecurser::mock(); + + $signature = Str::random(); + + $recurser->shouldReceive('withoutRecursion') + ->once() + ->withArgs(function (Recursable $target) use ($signature) { + return + $target->signature === $signature + && $target->object === null + && $target->hash === hash('xxh128', $signature); + }); + + without_recursion(fn () => null, null, $signature); + + $signature = Str::random(); + $object = (object) []; + + $recurser->shouldReceive('withoutRecursion') + ->once() + ->withArgs(function (Recursable $target) use ($signature, $object) { + return + $target->signature === $signature + && $target->object === $object + && $target->hash === hash('xxh128', $signature); + }); + + without_recursion(fn () => null, null, $signature, $object); + } + + public function testRecursesSameFunctionIfSignatureDifferent() + { + $fibonacci = function ($number) use (&$fibonacci) { + return without_recursion( + fn () => $fibonacci($number - 1) + $fibonacci($number - 2), + $number ? max(0, $number) : 1, + as: 'fibonacci:'.($number ? max(0, $number) : 1) + ); + }; + + $this->assertSame(0, $fibonacci(0)); + $this->assertSame(1, $fibonacci(1)); + $this->assertSame(1, $fibonacci(2)); + $this->assertSame(2, $fibonacci(3)); + $this->assertSame(3, $fibonacci(4)); + $this->assertSame(5, $fibonacci(5)); + $this->assertSame(8, $fibonacci(6)); + $this->assertSame(13, $fibonacci(7)); + $this->assertSame(21, $fibonacci(8)); + $this->assertSame(34, $fibonacci(9)); + $this->assertSame(55, $fibonacci(10)); + $this->assertSame(0, $fibonacci(-10)); + } +} + +class MockRecurser extends Recurser +{ + public static function mock() + { + return Recurser::$instance = m::mock(static::class); + } +} + +function recursive_rand(): string +{ + return without_recursion( + fn () => sprintf('%d:%s', rand(1, 10000), recursive_rand()), + rand(1, 10000) + ); +} + +class InvokableRecursiveRepeater +{ + public function __construct( + protected mixed $value, + ) { + // + } + + public function __invoke(int $times = 1): string + { + return without_recursion( + function () use ($times) { + $values = []; + + for ($i = 0; $i < $times; $i++) { + $values[] = (string) $this(); + } + + return implode(':', $values); + }, + $this->value, + ); + } +} + +class DoublyLinkedRecursiveList +{ + protected array $callCount = []; + + public function __construct( + public readonly int $id, + protected ?self $next = null, + protected ?self $prev = null, + ) { + $this->next?->setPrev($this); + $this->prev?->setNext($this); + } + + public static function make(int $id = 1, int $children = 0): self + { + return new self($id, $children > 0 ? self::make($id + 1, $children - 1) : null); + } + + public function getNext(): ?self + { + return $this->next; + } + + public function getPrev(): ?self + { + return $this->prev; + } + + public function setNext(self $next): void + { + if ($this->next !== $next) { + $this->next = $next; + + $next->setPrev($this); + } + } + + public function setPrev(self $prev): void + { + if ($this->prev !== $prev) { + $this->prev = $prev; + + $prev->setNext($this); + } + } + + public function ancestors(): array + { + return $this->prev?->upline() ?? []; + } + + public function children(): array + { + return $this->next?->downline() ?? []; + } + + public function upline(): array + { + $this->recordCall('upline'); + + return without_recursion(function () { + $this->recordCall('upline_callback'); + + return [ + $this->id, + ...($this->getPrev()?->upline() ?? []), + ]; + }, []); + } + + public function downline(): array + { + $this->recordCall('downline'); + + return without_recursion(function () { + $this->recordCall('downline_callback'); + + return [ + $this->id, + ...($this->getNext()?->downline() ?? []), + ]; + }, []); + } + + public function head(): self + { + $this->recordCall('head'); + + return without_recursion(function () { + $this->recordCall('head_callback'); + + return $this->getPrev()?->head() ?? $this; + }, function () { + $this->recordCall('head_recursive_callback'); + + return $this->getNext(); + }); + } + + public function tail(): self + { + $this->recordCall('tail'); + + return without_recursion(function () { + $this->recordCall('tail_callback'); + + return $this->getNext()?->tail() ?? $this; + }, function () { + $this->recordCall('tail_recursive_callback'); + + return $this->getPrev(); + }); + } + + public function pullCallCount(): array + { + return tap($this->callCount, fn () => $this->resetCallCount()); + } + + public function resetCallCount(): void + { + $this->callCount = []; + } + + protected function recordCall(string $function): void + { + $this->callCount[$function] ??= 0; + + $this->callCount[$function]++; + } +} diff --git a/tests/Support/SupportRecursableTest.php b/tests/Support/SupportRecursableTest.php new file mode 100644 index 000000000000..8a3e1ccb6258 --- /dev/null +++ b/tests/Support/SupportRecursableTest.php @@ -0,0 +1,277 @@ + 'foo', 'bar', null); + $recursableTwo = new Recursable(fn () => 'foo', 'bar', $one); + + $this->assertNull($recursableOne->object); + $this->assertSame($one, $recursableTwo->object); + + $this->assertSame($recursableOne, $recursableOne->for($two)); + $this->assertSame($two, $recursableOne->object); + + $this->assertSame($recursableTwo, $recursableTwo->for($two)); + $this->assertSame($one, $recursableTwo->object); + } + + public function testReturnAlwaysOverridesOnRecursion() + { + $recursable = new Recursable(fn () => 'foo', null, null); + + $this->assertNull($recursable->onRecursion); + + $this->assertSame($recursable, $recursable->return('bar')); + $this->assertSame('bar', $recursable->onRecursion); + + $callable = fn () => 'qux'; + + $this->assertSame($recursable, $recursable->return($callable)); + $this->assertSame($callable, $recursable->onRecursion); + + $this->assertSame($recursable, $recursable->return('baz')); + $this->assertSame('baz', $recursable->onRecursion); + } + + #[DataProvider('backtraceProvider')] + public function testTargetFromTrace(array $trace, array $target, string $signature) + { + $this->assertSame($target, RecursableStub::expose_targetFromTrace($trace)); + } + + #[DataProvider('limitProvider')] + public function testTargetFromTraceWithRealBacktrace(int $limit) + { + $target = RecursableStub::expose_targetFromTrace(test_backtrace($limit)); + + $this->assertSame(__FILE__, $target['file']); + $this->assertSame(__CLASS__, $target['class']); + $this->assertSame(__FUNCTION__, $target['function']); + $this->assertSame($this, $target['object']); + } + + public function testTargetFromTraceWithSingleFrameBacktrace() + { + $target = RecursableStub::expose_targetFromTrace(test_backtrace(1)); + + $this->assertSame(__FILE__, $target['file']); + $this->assertSame('', $target['class']); + $this->assertSame('', $target['function']); + $this->assertSame(null, $target['object']); + } + + #[DataProvider('backtraceProvider')] + public function testSignatureFromTrace(array $trace, array $target, string $signature) + { + $this->assertSame($signature, RecursableStub::expose_signatureFromTrace($trace)); + } + + #[DataProvider('limitProvider')] + public function testSignatureFromTraceWithRealBacktrace(int $limit) + { + $this->assertSame( + sprintf('%s:%s@%s', __FILE__, __CLASS__, __FUNCTION__), + RecursableStub::expose_signatureFromTrace(test_backtrace($limit)), + ); + } + + public function testSignatureFromTraceWithSingleFrameBacktrace() + { + $trace = test_backtrace(1); + + $this->assertSame( + sprintf('%s:%d', __FILE__, $trace[0]['line']), + RecursableStub::expose_signatureFromTrace($trace), + ); + } + + #[DataProvider('backtraceProvider')] + public function testHashFromTrace(array $trace, array $target, string $signature) + { + $this->assertSame(hash('xxh128', $signature), RecursableStub::expose_hashFromTrace($trace)); + } + + #[DataProvider('limitProvider')] + public function testHashFromTraceWithRealBacktrace(int $limit) + { + $this->assertSame( + hash('xxh128', sprintf('%s:%s@%s', __FILE__, __CLASS__, __FUNCTION__)), + RecursableStub::expose_hashFromTrace(test_backtrace($limit)), + ); + } + + public function testHashFromTraceWithSingleFrameBacktrace() + { + $trace = test_backtrace(1); + + $this->assertSame( + hash('xxh128', sprintf('%s:%d', __FILE__, $trace[0]['line'])), + RecursableStub::expose_hashFromTrace($trace), + ); + } + + #[DataProvider('signatureProvider')] + public function testHashFromSignature(string $signature) + { + $this->assertSame( + hash('xxh128', $signature), + RecursableStub::expose_hashFromSignature($signature), + ); + } + + #[DataProvider('backtraceProvider')] + public function testObjectFromTrace(array $trace, array $target, string $signature) + { + $this->assertSame($target['object'], RecursableStub::expose_objectFromTrace($trace)); + } + + #[DataProvider('limitProvider')] + public function testObjectFromTraceWithRealBacktrace(int $limit) + { + $this->assertSame($this, RecursableStub::expose_objectFromTrace(test_backtrace($limit))); + } + + public function testObjectFromTraceWithSingleFrameBacktrace() + { + $this->assertSame(null, RecursableStub::expose_objectFromTrace(test_backtrace(1))); + } + + #[DataProvider('backtraceProvider')] + public function testFromTraceCreatesRecursable(array $trace, array $target, string $signature) + { + $callback = fn () => 'foo'; + $onRecursion = 'bar'; + + $recursable = Recursable::fromTrace($trace, $callback, $onRecursion); + + $this->assertSame($target['object'], $recursable->object); + $this->assertSame($callback, $recursable->callback); + $this->assertSame($onRecursion, $recursable->onRecursion); + $this->assertSame(hash('xxh128', $signature), $recursable->hash); + $this->assertSame($signature, $recursable->signature); + } + + #[DataProvider('limitProvider')] + public function testFromTraceCreatesRecursableWithRealBacktrace(int $limit) + { + $callback = fn () => 'foo'; + $onRecursion = 'bar'; + $signature = sprintf('%s:%s@%s', __FILE__, __CLASS__, __FUNCTION__); + + $recursable = Recursable::fromTrace(test_backtrace($limit), $callback, $onRecursion); + + $this->assertSame($this, $recursable->object); + $this->assertSame($callback, $recursable->callback); + $this->assertSame($onRecursion, $recursable->onRecursion); + $this->assertSame(hash('xxh128', $signature), $recursable->hash); + $this->assertSame($signature, $recursable->signature); + } + + public function testFromTraceCreatesRecursableWithSingleFrameBacktrace() + { + $callback = fn () => 'foo'; + $onRecursion = 'bar'; + $trace = test_backtrace(1); + $signature = sprintf('%s:%d', __FILE__, $trace[0]['line']); + + $recursable = Recursable::fromTrace($trace, $callback, $onRecursion); + + $this->assertSame(null, $recursable->object); + $this->assertSame($callback, $recursable->callback); + $this->assertSame($onRecursion, $recursable->onRecursion); + $this->assertSame(hash('xxh128', $signature), $recursable->hash); + $this->assertSame($signature, $recursable->signature); + } + + #[DataProvider('signatureProvider')] + public function testFromSignatureCreatesRecursable(string $signature) + { + $callback = fn () => 'foo'; + $onRecursion = 'bar'; + $object = (object) []; + + $recursable = Recursable::fromSignature($signature, $callback, $onRecursion, $object); + + $this->assertSame($object, $recursable->object); + $this->assertSame($callback, $recursable->callback); + $this->assertSame($onRecursion, $recursable->onRecursion); + $this->assertSame(hash('xxh128', $signature ?: Recursable::BLANK_SIGNATURE), $recursable->hash); + $this->assertSame($signature ?: Recursable::BLANK_SIGNATURE, $recursable->signature); + } + + public static function backtraceProvider(): array + { + $empty = ['file' => '', 'class' => '', 'function' => '', 'line' => 0, 'object' => null]; + $object = (object) []; + + return [ + 'no frames' => [[], $empty, ':0'], + 'one empty frame' => [[[]], $empty, ':0'], + 'two empty frames' => [[[], []], $empty, ':0'], + 'empty first frame' => [ + [[], ['class' => 'SomeClass', 'function' => 'someMethod', 'object' => $object]], + [...$empty, 'class' => 'SomeClass', 'function' => 'someMethod', 'object' => $object], + ':SomeClass@someMethod', + ], + 'single frame' => [ + [['file' => '/path/to/file.php', 'line' => 42]], + [...$empty, 'file' => '/path/to/file.php', 'line' => 42], + '/path/to/file.php:42', + ], + 'full trace' => [ + [['file' => '/path/to/file.php', 'line' => 42], ['class' => 'SomeClass', 'function' => 'someMethod', 'object' => $object]], + ['file' => '/path/to/file.php', 'class' => 'SomeClass', 'function' => 'someMethod', 'line' => 42, 'object' => $object], + '/path/to/file.php:SomeClass@someMethod', + ], + ]; + } + + public static function limitProvider(): array + { + return [ + 'two frames' => [2], + 'three frames' => [3], + 'full trace' => [0], + ]; + } + + public static function signatureProvider(): array + { + return [ + 'blank' => [''], + 'global function' => ['/some/file.php:42'], + 'class' => ['/some/file.php:SomeClass@someMethod'], + 'random' => [base64_encode(random_bytes(16))], + ]; + } +} + +function test_backtrace(int $limit = 2): array +{ + return debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, $limit); +} + +class RecursableStub extends Recursable +{ + public static function __callStatic(string $method, array $parameters) + { + $method = str_starts_with($method, 'expose_') ? Str::after($method, 'expose_') : $method; + + return method_exists(static::class, $method) + ? static::$method(...$parameters) + : throw new BadMethodCallException(sprintf('Static Method %s::%s does not exist.', static::class, $method)); + } +} diff --git a/tests/Support/SupportRecurserTest.php b/tests/Support/SupportRecurserTest.php new file mode 100644 index 000000000000..11b3c8dc0a33 --- /dev/null +++ b/tests/Support/SupportRecurserTest.php @@ -0,0 +1,293 @@ +assertNull(RecurserStub::getInstance()); + $instance = RecurserStub::instance(); + + $this->assertInstanceOf(RecurserStub::class, $instance); + $this->assertSame($instance, RecurserStub::getInstance()); + $this->assertSame($instance, RecurserStub::instance()); + + RecurserStub::flush(); + + $this->assertInstanceOf(RecurserStub::class, RecurserStub::instance()); + $this->assertNotSame($instance, RecurserStub::getInstance()); + $this->assertNotSame($instance, RecurserStub::instance()); + $this->assertSame(RecurserStub::getInstance(), RecurserStub::instance()); + } + + public function testRecurserStackManagement() + { + $one = (object) []; + $two = (object) []; + + $recurser = RecurserStub::instance(); + + $this->assertSame(0, $recurser->getCache()->count()); + $this->assertFalse($recurser->getCache()->offsetExists($one)); + $this->assertFalse($recurser->getCache()->offsetExists($two)); + $this->assertSame([], $recurser->expose_getStack($one)); + $this->assertSame([], $recurser->expose_getStack($two)); + + $recurser->expose_setStack($one, ['foo' => 'bar']); + $this->assertSame(1, $recurser->getCache()->count()); + $this->assertTrue($recurser->getCache()->offsetExists($one)); + $this->assertFalse($recurser->getCache()->offsetExists($two)); + $this->assertSame(['foo' => 'bar'], $recurser->expose_getStack($one)); + $this->assertSame([], $recurser->expose_getStack($two)); + + $recurser->expose_setStack($two, ['foo' => 'bar', 'bing' => 'bang']); + $this->assertSame(2, $recurser->getCache()->count()); + $this->assertTrue($recurser->getCache()->offsetExists($one)); + $this->assertTrue($recurser->getCache()->offsetExists($two)); + $this->assertSame(['foo' => 'bar'], $recurser->expose_getStack($one)); + $this->assertSame(['foo' => 'bar', 'bing' => 'bang'], $recurser->expose_getStack($two)); + + $recurser->expose_setStack($one, []); + $this->assertSame(1, $recurser->getCache()->count()); + $this->assertFalse($recurser->getCache()->offsetExists($one)); + $this->assertTrue($recurser->getCache()->offsetExists($two)); + $this->assertSame([], $recurser->expose_getStack($one)); + $this->assertSame(['foo' => 'bar', 'bing' => 'bang'], $recurser->expose_getStack($two)); + + $recurser->expose_setStack($two, []); + $this->assertSame(0, $recurser->getCache()->count()); + $this->assertFalse($recurser->getCache()->offsetExists($one)); + $this->assertFalse($recurser->getCache()->offsetExists($two)); + $this->assertSame([], $recurser->expose_getStack($one)); + $this->assertSame([], $recurser->expose_getStack($two)); + } + + public function testRecurserRecursableManagement() + { + $recurser = RecurserStub::instance(); + $cache = $recurser->getCache(); + + $one = (object) []; + $two = (object) []; + + $foo = RecursableMock::make('foo', 'oof', $one, 'one@foo', 'foo_hash'); + $bar = RecursableMock::make('bar', 'baz', $one, 'one@bar', 'bar_hash'); + $bing = RecursableMock::make('bing', 'bang', $two, 'two@bing', 'bing_hash'); + + $this->assertSame(0, $cache->count()); + $this->assertFalse($recurser->expose_hasValue($foo)); + $this->assertFalse($recurser->expose_hasValue($bar)); + $this->assertFalse($recurser->expose_hasValue($bing)); + + $recurser->expose_setRecursedValue($foo); + $this->assertSame(1, $cache->count()); + $this->assertTrue($cache->offsetExists($one)); + $this->assertSame(['foo_hash' => 'oof'], $cache->offsetGet($one)); + $this->assertFalse($cache->offsetExists($two)); + + $this->assertTrue($recurser->expose_hasValue($foo)); + $this->assertFalse($recurser->expose_hasValue($bar)); + $this->assertFalse($recurser->expose_hasValue($bing)); + $this->assertSame('oof', $recurser->expose_getRecursedValue($foo)); + + $recurser->expose_setRecursedValue($bing); + $this->assertSame(2, $cache->count()); + $this->assertTrue($cache->offsetExists($one)); + $this->assertTrue($cache->offsetExists($two)); + $this->assertSame(['bing_hash' => 'bang'], $cache->offsetGet($two)); + + $this->assertTrue($recurser->expose_hasValue($foo)); + $this->assertFalse($recurser->expose_hasValue($bar)); + $this->assertTrue($recurser->expose_hasValue($bing)); + $this->assertSame('bang', $recurser->expose_getRecursedValue($bing)); + + $recurser->expose_setRecursedValue($bar); + $this->assertSame(2, $cache->count()); + $this->assertSame(['foo_hash' => 'oof', 'bar_hash' => 'baz'], $cache->offsetGet($one)); + + $this->assertTrue($recurser->expose_hasValue($foo)); + $this->assertTrue($recurser->expose_hasValue($bar)); + $this->assertTrue($recurser->expose_hasValue($bing)); + $this->assertSame('baz', $recurser->expose_getRecursedValue($bar)); + + $recurser->expose_release($foo); + $this->assertSame(2, $cache->count()); + $this->assertTrue($cache->offsetExists($one)); + $this->assertTrue($cache->offsetExists($two)); + $this->assertSame(['bar_hash' => 'baz'], $cache->offsetGet($one)); + + $recurser->expose_release($bar); + $this->assertSame(1, $cache->count()); + $this->assertFalse($cache->offsetExists($one)); + $this->assertTrue($cache->offsetExists($two)); + + $this->assertFalse($recurser->expose_hasValue($foo)); + $this->assertFalse($recurser->expose_hasValue($bar)); + $this->assertTrue($recurser->expose_hasValue($bing)); + + $this->expectException(RecursableNotFoundException::class); + $this->expectExceptionMessage('[one@foo]'); + $recurser->expose_getRecursedValue($foo); + + $this->expectExceptionMessage('[one@bar]'); + $recurser->expose_getRecursedValue($bar); + } + + public function testGetRecursedValueResolvesCallablesWhenCalled() + { + $recurser = RecurserStub::instance(); + $cache = $recurser->getCache(); + + $callable = fn () => 'foo'; + + $target = new Recursable( + fn () => 'bar', + $callable, + $this, + 'test', + ); + + $this->assertFalse($recurser->expose_hasValue($target)); + $recurser->expose_setRecursedValue($target); + $this->assertTrue($recurser->expose_hasValue($target)); + $this->assertSame([$target->hash => $callable], $cache->offsetGet($this)); + $this->assertSame('foo', $recurser->expose_getRecursedValue($target)); + $this->assertSame([$target->hash => 'foo'], $cache->offsetGet($this)); + + $cache->offsetSet($this, [$target->hash => fn () => 'bing']); + $this->assertSame('bing', $recurser->expose_getRecursedValue($target)); + $this->assertSame([$target->hash => 'bing'], $cache->offsetGet($this)); + } + + public function testWithoutRecursionCallsSetsGlobalObject() + { + $recurser = RecurserStub::instance(); + $target = RecursableMock::make(); + + $this->assertNull($target->object); + $recurser->withoutRecursion($target); + $this->assertSame($recurser->globalContext, $target->object); + } + + public function testWithoutRecursionCallsRecursableCallback() + { + $called = false; + + $target = RecursableMock::make(function () use (&$called) { + return $called = true; + }); + + $this->assertFalse($called); + $this->assertTrue(RecurserStub::instance()->withoutRecursion($target)); + $this->assertTrue($called); + } + + public function testWithoutRecursionAddsAndRemovesItemFromCallStackForObject() + { + $recurser = RecurserStub::instance(); + $cache = $recurser->getCache(); + + $object = new class($this) + { + public function __construct( + protected TestCase $test, + ) { + // + } + + public function __invoke(): void + { + $recurser = RecurserStub::instance(); + $cache = $recurser->getCache(); + + $this->test->assertSame(1, $cache->count()); + $this->test->assertTrue($cache->offsetExists($this)); + $this->test->assertSame(['foo' => null], $recurser->expose_getStack($this)); + } + }; + + $target = new Recursable( + $object, + null, + $object, + '$object@__invoke', + 'foo', + ); + + $this->assertSame(0, $cache->count()); + $this->assertFalse($recurser->expose_hasValue($target)); + $this->assertSame([], $recurser->expose_getStack($object)); + + $recurser->withoutRecursion($target); + + $this->assertSame(0, $cache->count()); + $this->assertFalse($recurser->expose_hasValue($target)); + $this->assertSame([], $recurser->expose_getStack($object)); + } +} + +class RecurserStub extends Recurser +{ + public static function getInstance(): ?self + { + return static::$instance; + } + + public function getCache(): WeakMap + { + return $this->cache; + } + + public function __call(string $method, array $parameters) + { + $method = str_starts_with($method, 'expose_') ? Str::after($method, 'expose_') : $method; + + return method_exists($this, $method) ? $this->$method(...$parameters) : throw new BadMethodCallException(sprintf( + 'Method %s::%s does not exist.', static::class, $method + )); + } +} + +class RecursableMock extends Recursable +{ + public static function make( + mixed $first = null, + mixed $second = null, + ?object $for = null, + string $signature = 'test', + ?string $hash = null, + ): static { + return new static( + is_callable($first) ? $first : fn () => $first, + $second, + $for, + $signature, + $hash, + ); + } +}