Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Handle circular references in model serialization #52461

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace Illuminate\Database\Eloquent\Concerns;

use Illuminate\Support\Arr;
use Illuminate\Support\Onceable;
use WeakMap;

trait PreventsCircularRecursion
{
/**
* The cache of objects processed to prevent infinite recursion.
*
* @var WeakMap<static, array<string, mixed>>
*/
protected static $recursionCache;

/**
* Prevent a method from being called multiple times on the same object within the same call stack.
*
* @param callable $callback
* @param mixed $default
* @return mixed
*/
protected function withoutRecursion($callback, $default = null)
{
$trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2);

$onceable = Onceable::tryFromTrace($trace, $callback);

$stack = static::getRecursiveCallStack($this);

if (array_key_exists($onceable->hash, $stack)) {
return is_callable($stack[$onceable->hash])
? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash]))
: $stack[$onceable->hash];
}

try {
static::setRecursiveCallValue($this, $onceable->hash, $default);

return call_user_func($onceable->callable);
} finally {
static::clearRecursiveCallValue($this, $onceable->hash);
}
}

/**
* Remove an entry from the recursion cache for an object.
*
* @param object $object
* @param string $hash
*/
protected static function clearRecursiveCallValue($object, string $hash)
{
if ($stack = Arr::except(static::getRecursiveCallStack($object), $hash)) {
static::getRecursionCache()->offsetSet($object, $stack);
} elseif (static::getRecursionCache()->offsetExists($object)) {
static::getRecursionCache()->offsetUnset($object);
}
}

/**
* Get the stack of methods being called recursively for the current object.
*
* @param object $object
* @return array
*/
protected static function getRecursiveCallStack($object): array
{
return static::getRecursionCache()->offsetExists($object)
? static::getRecursionCache()->offsetGet($object)
: [];
}

/**
* Get the current recursion cache being used by the model.
*
* @return WeakMap
*/
protected static function getRecursionCache()
{
return static::$recursionCache ??= new WeakMap();
}

/**
* Set a value in the recursion cache for the given object and method.
*
* @param object $object
* @param string $hash
* @param mixed $value
* @return mixed
*/
protected static function setRecursiveCallValue($object, string $hash, $value)
{
static::getRecursionCache()->offsetSet(
$object,
tap(static::getRecursiveCallStack($object), fn (&$stack) => $stack[$hash] = $value),
);

return static::getRecursiveCallStack($object)[$hash];
}
}
70 changes: 39 additions & 31 deletions src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
Concerns\HasUniqueIds,
Concerns\HidesAttributes,
Concerns\GuardsAttributes,
Concerns\PreventsCircularRecursion,
ForwardsCalls;
/** @use HasCollection<\Illuminate\Database\Eloquent\Collection<array-key, static>> */
use HasCollection;
Expand Down Expand Up @@ -1083,25 +1084,27 @@ protected function decrementQuietly($column, $amount = 1, array $extra = [])
*/
public function push()
{
if (! $this->save()) {
return false;
}

// To sync all of the relationships to the database, we will simply spin through
// the relationships and save each model via this "push" method, which allows
// us to recurse into all of these nested relations for the model instance.
foreach ($this->relations as $models) {
$models = $models instanceof Collection
? $models->all() : [$models];
return $this->withoutRecursion(function () {
if (! $this->save()) {
return false;
}

foreach (array_filter($models) as $model) {
if (! $model->push()) {
return false;
// To sync all of the relationships to the database, we will simply spin through
// the relationships and save each model via this "push" method, which allows
// us to recurse into all of these nested relations for the model instance.
foreach ($this->relations as $models) {
$models = $models instanceof Collection
? $models->all() : [$models];

foreach (array_filter($models) as $model) {
if (! $model->push()) {
return false;
}
}
}
}

return true;
return true;
}, true);
}

/**
Expand Down Expand Up @@ -1644,7 +1647,10 @@ public function callNamedScope($scope, array $parameters = [])
*/
public function toArray()
{
return array_merge($this->attributesToArray(), $this->relationsToArray());
return $this->withoutRecursion(
fn () => array_merge($this->attributesToArray(), $this->relationsToArray()),
fn () => $this->attributesToArray(),
);
}

/**
Expand Down Expand Up @@ -1991,29 +1997,31 @@ public function getQueueableId()
*/
public function getQueueableRelations()
{
$relations = [];
return $this->withoutRecursion(function () {
$relations = [];

foreach ($this->getRelations() as $key => $relation) {
if (! method_exists($this, $key)) {
continue;
}
foreach ($this->getRelations() as $key => $relation) {
if (! method_exists($this, $key)) {
continue;
}

$relations[] = $key;
$relations[] = $key;

if ($relation instanceof QueueableCollection) {
foreach ($relation->getQueueableRelations() as $collectionValue) {
$relations[] = $key.'.'.$collectionValue;
if ($relation instanceof QueueableCollection) {
foreach ($relation->getQueueableRelations() as $collectionValue) {
$relations[] = $key.'.'.$collectionValue;
}
}
}

if ($relation instanceof QueueableEntity) {
foreach ($relation->getQueueableRelations() as $entityValue) {
$relations[] = $key.'.'.$entityValue;
if ($relation instanceof QueueableEntity) {
foreach ($relation->getQueueableRelations() as $entityValue) {
$relations[] = $key.'.'.$entityValue;
}
}
}
}

return array_unique($relations);
return array_unique($relations);
}, []);
}

/**
Expand Down
162 changes: 162 additions & 0 deletions tests/Database/DatabaseConcernsPreventsCircularRecursionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

namespace Illuminate\Tests\Database;

use Illuminate\Database\Eloquent\Concerns\PreventsCircularRecursion;
use PHPUnit\Framework\TestCase;

class DatabaseConcernsPreventsCircularRecursionTest extends TestCase
{
public function setUp(): void
{
parent::setUp();

PreventsCircularRecursionWithRecursiveMethod::$globalStack = 0;
}

public function testRecursiveCallsArePreventedWithoutPreventingSubsequentCalls()
{
$instance = new PreventsCircularRecursionWithRecursiveMethod();

$this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(0, $instance->instanceStack);

$this->assertEquals(0, $instance->callStack());
samlev marked this conversation as resolved.
Show resolved Hide resolved
$this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(1, $instance->instanceStack);

$this->assertEquals(1, $instance->callStack());
$this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
}

public function testRecursiveCallsAreLimitedToIndividualInstances()
{
$instance = new PreventsCircularRecursionWithRecursiveMethod();
$other = $instance->other;

$this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(0, $instance->instanceStack);
$this->assertEquals(0, $other->instanceStack);

$instance->callStack();
$this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(1, $instance->instanceStack);
$this->assertEquals(0, $other->instanceStack);

$instance->callStack();
$this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
$this->assertEquals(0, $other->instanceStack);

$other->callStack();
$this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
$this->assertEquals(1, $other->instanceStack);

$other->callStack();
$this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
$this->assertEquals(2, $other->instanceStack);
}

public function testRecursiveCallsToCircularReferenceCallsOtherInstanceOnce()
{
$instance = new PreventsCircularRecursionWithRecursiveMethod();
$other = $instance->other;

$this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(0, $instance->instanceStack);
$this->assertEquals(0, $other->instanceStack);

$instance->callOtherStack();
$this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(1, $instance->instanceStack);
$this->assertEquals(1, $other->instanceStack);

$instance->callOtherStack();
$this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
$this->assertEquals(2, $other->instanceStack);

$other->callOtherStack();
$this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(3, $other->instanceStack);
$this->assertEquals(3, $instance->instanceStack);

$other->callOtherStack();
$this->assertEquals(8, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(4, $other->instanceStack);
$this->assertEquals(4, $instance->instanceStack);
}

public function testRecursiveCallsToCircularLinkedListCallsEachInstanceOnce()
{
$instance = new PreventsCircularRecursionWithRecursiveMethod();
$second = $instance->other;
$third = new PreventsCircularRecursionWithRecursiveMethod($second);
$instance->other = $third;

$this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(0, $instance->instanceStack);
$this->assertEquals(0, $second->instanceStack);
$this->assertEquals(0, $third->instanceStack);

$instance->callOtherStack();
$this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(1, $instance->instanceStack);
$this->assertEquals(1, $second->instanceStack);
$this->assertEquals(1, $third->instanceStack);

$second->callOtherStack();
$this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(2, $instance->instanceStack);
$this->assertEquals(2, $second->instanceStack);
$this->assertEquals(2, $third->instanceStack);

$third->callOtherStack();
$this->assertEquals(9, PreventsCircularRecursionWithRecursiveMethod::$globalStack);
$this->assertEquals(3, $instance->instanceStack);
$this->assertEquals(3, $second->instanceStack);
$this->assertEquals(3, $third->instanceStack);
}
}

class PreventsCircularRecursionWithRecursiveMethod
{
use PreventsCircularRecursion;

public function __construct(
public ?PreventsCircularRecursionWithRecursiveMethod $other = null,
) {
$this->other ??= new PreventsCircularRecursionWithRecursiveMethod($this);
}

public static int $globalStack = 0;
public int $instanceStack = 0;

public function callStack(): int
{
return $this->withoutRecursion(
function () {
static::$globalStack++;
$this->instanceStack++;

return $this->callStack();
},
$this->instanceStack
);
}

public function callOtherStack(): int
{
return $this->withoutRecursion(
function () {
$this->other->callStack();

return $this->other->callOtherStack();
},
$this->instanceStack
);
}
}
Loading