Skip to content

Commit 2835e2b

Browse files
samlevtaylorotwell
andauthored
[11.x] Handle circular references in model serialization (#52461)
* [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 <taylor@laravel.com>
1 parent 8a4a52e commit 2835e2b

File tree

4 files changed

+580
-31
lines changed

4 files changed

+580
-31
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent\Concerns;
4+
5+
use Illuminate\Support\Arr;
6+
use Illuminate\Support\Onceable;
7+
use WeakMap;
8+
9+
trait PreventsCircularRecursion
10+
{
11+
/**
12+
* The cache of objects processed to prevent infinite recursion.
13+
*
14+
* @var WeakMap<static, array<string, mixed>>
15+
*/
16+
protected static $recursionCache;
17+
18+
/**
19+
* Prevent a method from being called multiple times on the same object within the same call stack.
20+
*
21+
* @param callable $callback
22+
* @param mixed $default
23+
* @return mixed
24+
*/
25+
protected function withoutRecursion($callback, $default = null)
26+
{
27+
$trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2);
28+
29+
$onceable = Onceable::tryFromTrace($trace, $callback);
30+
31+
$stack = static::getRecursiveCallStack($this);
32+
33+
if (array_key_exists($onceable->hash, $stack)) {
34+
return is_callable($stack[$onceable->hash])
35+
? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash]))
36+
: $stack[$onceable->hash];
37+
}
38+
39+
try {
40+
static::setRecursiveCallValue($this, $onceable->hash, $default);
41+
42+
return call_user_func($onceable->callable);
43+
} finally {
44+
static::clearRecursiveCallValue($this, $onceable->hash);
45+
}
46+
}
47+
48+
/**
49+
* Remove an entry from the recursion cache for an object.
50+
*
51+
* @param object $object
52+
* @param string $hash
53+
*/
54+
protected static function clearRecursiveCallValue($object, string $hash)
55+
{
56+
if ($stack = Arr::except(static::getRecursiveCallStack($object), $hash)) {
57+
static::getRecursionCache()->offsetSet($object, $stack);
58+
} elseif (static::getRecursionCache()->offsetExists($object)) {
59+
static::getRecursionCache()->offsetUnset($object);
60+
}
61+
}
62+
63+
/**
64+
* Get the stack of methods being called recursively for the current object.
65+
*
66+
* @param object $object
67+
* @return array
68+
*/
69+
protected static function getRecursiveCallStack($object): array
70+
{
71+
return static::getRecursionCache()->offsetExists($object)
72+
? static::getRecursionCache()->offsetGet($object)
73+
: [];
74+
}
75+
76+
/**
77+
* Get the current recursion cache being used by the model.
78+
*
79+
* @return WeakMap
80+
*/
81+
protected static function getRecursionCache()
82+
{
83+
return static::$recursionCache ??= new WeakMap();
84+
}
85+
86+
/**
87+
* Set a value in the recursion cache for the given object and method.
88+
*
89+
* @param object $object
90+
* @param string $hash
91+
* @param mixed $value
92+
* @return mixed
93+
*/
94+
protected static function setRecursiveCallValue($object, string $hash, $value)
95+
{
96+
static::getRecursionCache()->offsetSet(
97+
$object,
98+
tap(static::getRecursiveCallStack($object), fn (&$stack) => $stack[$hash] = $value),
99+
);
100+
101+
return static::getRecursiveCallStack($object)[$hash];
102+
}
103+
}

src/Illuminate/Database/Eloquent/Model.php

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt
3535
Concerns\HasUniqueIds,
3636
Concerns\HidesAttributes,
3737
Concerns\GuardsAttributes,
38+
Concerns\PreventsCircularRecursion,
3839
ForwardsCalls;
3940
/** @use HasCollection<\Illuminate\Database\Eloquent\Collection<array-key, static>> */
4041
use HasCollection;
@@ -1083,25 +1084,27 @@ protected function decrementQuietly($column, $amount = 1, array $extra = [])
10831084
*/
10841085
public function push()
10851086
{
1086-
if (! $this->save()) {
1087-
return false;
1088-
}
1089-
1090-
// To sync all of the relationships to the database, we will simply spin through
1091-
// the relationships and save each model via this "push" method, which allows
1092-
// us to recurse into all of these nested relations for the model instance.
1093-
foreach ($this->relations as $models) {
1094-
$models = $models instanceof Collection
1095-
? $models->all() : [$models];
1087+
return $this->withoutRecursion(function () {
1088+
if (! $this->save()) {
1089+
return false;
1090+
}
10961091

1097-
foreach (array_filter($models) as $model) {
1098-
if (! $model->push()) {
1099-
return false;
1092+
// To sync all of the relationships to the database, we will simply spin through
1093+
// the relationships and save each model via this "push" method, which allows
1094+
// us to recurse into all of these nested relations for the model instance.
1095+
foreach ($this->relations as $models) {
1096+
$models = $models instanceof Collection
1097+
? $models->all() : [$models];
1098+
1099+
foreach (array_filter($models) as $model) {
1100+
if (! $model->push()) {
1101+
return false;
1102+
}
11001103
}
11011104
}
1102-
}
11031105

1104-
return true;
1106+
return true;
1107+
}, true);
11051108
}
11061109

11071110
/**
@@ -1657,7 +1660,10 @@ public function callNamedScope($scope, array $parameters = [])
16571660
*/
16581661
public function toArray()
16591662
{
1660-
return array_merge($this->attributesToArray(), $this->relationsToArray());
1663+
return $this->withoutRecursion(
1664+
fn () => array_merge($this->attributesToArray(), $this->relationsToArray()),
1665+
fn () => $this->attributesToArray(),
1666+
);
16611667
}
16621668

16631669
/**
@@ -2004,29 +2010,31 @@ public function getQueueableId()
20042010
*/
20052011
public function getQueueableRelations()
20062012
{
2007-
$relations = [];
2013+
return $this->withoutRecursion(function () {
2014+
$relations = [];
20082015

2009-
foreach ($this->getRelations() as $key => $relation) {
2010-
if (! method_exists($this, $key)) {
2011-
continue;
2012-
}
2016+
foreach ($this->getRelations() as $key => $relation) {
2017+
if (! method_exists($this, $key)) {
2018+
continue;
2019+
}
20132020

2014-
$relations[] = $key;
2021+
$relations[] = $key;
20152022

2016-
if ($relation instanceof QueueableCollection) {
2017-
foreach ($relation->getQueueableRelations() as $collectionValue) {
2018-
$relations[] = $key.'.'.$collectionValue;
2023+
if ($relation instanceof QueueableCollection) {
2024+
foreach ($relation->getQueueableRelations() as $collectionValue) {
2025+
$relations[] = $key.'.'.$collectionValue;
2026+
}
20192027
}
2020-
}
20212028

2022-
if ($relation instanceof QueueableEntity) {
2023-
foreach ($relation->getQueueableRelations() as $entityValue) {
2024-
$relations[] = $key.'.'.$entityValue;
2029+
if ($relation instanceof QueueableEntity) {
2030+
foreach ($relation->getQueueableRelations() as $entityValue) {
2031+
$relations[] = $key.'.'.$entityValue;
2032+
}
20252033
}
20262034
}
2027-
}
20282035

2029-
return array_unique($relations);
2036+
return array_unique($relations);
2037+
}, []);
20302038
}
20312039

20322040
/**

0 commit comments

Comments
 (0)