Skip to content

Commit

Permalink
[11.x] Handle circular references in model serialization (#52461)
Browse files Browse the repository at this point in the history
* [11.x] Added failing test for serializing circular relations

If a circular relationship is set up between two models using
 `setRelation()` (or similar methods) then calling
 `$model->relationsToArray()` will call `toArray()` on each related
 model, which will in turn call `relationsToArray()`. In an instance
 where one of the related models is an object that has already had
 `toArray()` called further up the stack, it will infinitely recurse
  down and result in a stack overflow.

The same issue exists with `getQueueableRelations()`, `push()`, and
 potentially other methods. This adds tests which will fail if one of
 the known potentially problematic methods gets into a recursive loop.

* [11.x] Added PreventsCircularRecursion

This adds a trait for Eloquent which can be used to prevent recursively
 serializing circular references.

* [11.x] Changed the name to `withoutRecursion()`, accept a callable default

* formatting

* [11.x] Delay calling a "default" callback until the last possible second

* [11.x] Added additional tests for "callable" defaults

---------

Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
samlev and taylorotwell authored Aug 22, 2024
1 parent 8a4a52e commit 2835e2b
Show file tree
Hide file tree
Showing 4 changed files with 580 additions and 31 deletions.
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 @@ -1657,7 +1660,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 @@ -2004,29 +2010,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
Loading

0 comments on commit 2835e2b

Please sign in to comment.