Skip to content

Commit

Permalink
[11.x] Added PreventsCircularRecursion
Browse files Browse the repository at this point in the history
This adds a trait for Eloquent which can be used to prevent recursively
 serializing circular references.
  • Loading branch information
samlev committed Aug 13, 2024
1 parent 287e737 commit 396b9ce
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 60 deletions.
65 changes: 35 additions & 30 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,41 +367,46 @@ protected function getArrayableAppends()
*/
public function relationsToArray()
{
$attributes = [];

foreach ($this->getArrayableRelations() as $key => $value) {
// If the values implement the Arrayable interface we can just call this
// toArray method on the instances which will convert both models and
// collections to their proper array form and we'll set the values.
if ($value instanceof Arrayable) {
$relation = $value->toArray();
}
return $this->once(function () {
$attributes = [];

foreach ($this->getArrayableRelations() as $key => $value) {
// If the values implement the Arrayable interface we can just call this
// toArray method on the instances which will convert both models and
// collections to their proper array form and we'll set the values.
if ($value instanceof Arrayable) {
$relation = $value->toArray();
}

// If the value is null, we'll still go ahead and set it in this list of
// attributes, since null is used to represent empty relationships if
// it has a has one or belongs to type relationships on the models.
elseif (is_null($value)) {
$relation = $value;
}
// If the value is null, we'll still go ahead and set it in this list of
// attributes, since null is used to represent empty relationships if
// it has a has one or belongs to type relationships on the models.
elseif (is_null($value)) {
$relation = $value;
}

// If the relationships snake-casing is enabled, we will snake case this
// key so that the relation attribute is snake cased in this returned
// array to the developers, making this consistent with attributes.
if (static::$snakeAttributes) {
$key = Str::snake($key);
}
// If the relationships snake-casing is enabled, we will snake case this
// key so that the relation attribute is snake cased in this returned
// array to the developers, making this consistent with attributes.
if (static::$snakeAttributes) {
$key = Str::snake($key);
}

// If the relation value has been set, we will set it on this attributes
// list for returning. If it was not arrayable or null, we'll not set
// the value on the array because it is some type of invalid value.
if (array_key_exists('relation', get_defined_vars())) { // check if $relation is in scope (could be null)
$attributes[$key] = $relation ?? null;
}
// If the relation value has been set, we will set it on this attributes
// list for returning. If it was not arrayable or null, we'll not set
// the value on the array because it is some type of invalid value.
if (array_key_exists(
'relation',
get_defined_vars()
)) { // check if $relation is in scope (could be null)
$attributes[$key] = $relation ?? null;
}

unset($relation);
}
unset($relation);
}

return $attributes;
return $attributes;
}, []);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Illuminate\Database\Eloquent\Concerns;

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

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

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

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

/**
* 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 once($callback, $default = null)
{
$onceable = Onceable::tryFromTrace(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2), $callback);

$object = $onceable->object ?? $this;
$stack = static::getRecursiveCallStack($object);

if (isset($stack[$onceable->hash])) {
return $stack[$onceable->hash];
}

try {
// Set the default first to prevent recursion
$stack[$onceable->hash] = $default;
static::getRecursionCache()->offsetSet($object, $stack);

return call_user_func($onceable->callable);
} finally {
if ($stack = Arr::except($this->getRecursiveCallStack($object), $onceable->hash)) {
static::getRecursionCache()->offsetSet($object, $stack);
} elseif (static::getRecursionCache()->offsetExists($object)) {
static::getRecursionCache()->offsetUnset($object);
}
}
}
}
65 changes: 35 additions & 30 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->once(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 @@ -1991,29 +1994,31 @@ public function getQueueableId()
*/
public function getQueueableRelations()
{
$relations = [];
return $this->once(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

0 comments on commit 396b9ce

Please sign in to comment.