From 396b9cef1ca4c92408a6de443970e1ffdb17cba8 Mon Sep 17 00:00:00 2001 From: Samuel Levy Date: Mon, 12 Aug 2024 16:22:30 +1000 Subject: [PATCH] [11.x] Added PreventsCircularRecursion This adds a trait for Eloquent which can be used to prevent recursively serializing circular references. --- .../Eloquent/Concerns/HasAttributes.php | 65 +++++++++-------- .../Concerns/PreventsCircularRecursion.php | 72 +++++++++++++++++++ src/Illuminate/Database/Eloquent/Model.php | 65 +++++++++-------- 3 files changed, 142 insertions(+), 60 deletions(-) create mode 100644 src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 31564c1f56a8..24f57bd68cff 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -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; + }, []); } /** diff --git a/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php new file mode 100644 index 000000000000..662a8331259d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php @@ -0,0 +1,72 @@ +> + */ + 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); + } + } + } +} diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 59b81aeed178..ffc548844d39 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -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> */ use HasCollection; @@ -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); } /** @@ -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); + }, []); } /**