Skip to content

Commit

Permalink
[11.x] Added failing test for serializing circular relations
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
samlev committed Aug 12, 2024
1 parent 6ce800f commit 287e737
Showing 1 changed file with 228 additions and 0 deletions.
228 changes: 228 additions & 0 deletions tests/Database/DatabaseEloquentModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,28 @@ public function testPushManyRelation()
$this->assertEquals([2, 3], $model->relationMany->pluck('id')->all());
}

public function testPushCircularRelations()
{
$parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]);
$lastId = $parent->id;
$parent->setRelation('self', $parent);

$children = new Collection();
for ($count = 0; $count < 2; $count++) {
$child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]);
$child->setRelation('parent', $parent);
$child->setRelation('self', $child);
$children->push($child);
}
$parent->setRelation('children', $children);

try {
$this->assertTrue($parent->push());
} catch (\RuntimeException $e) {
$this->fail($e->getMessage());
}
}

public function testNewQueryReturnsEloquentQueryBuilder()
{
$conn = m::mock(Connection::class);
Expand Down Expand Up @@ -1231,6 +1253,120 @@ public function testToArray()
$this->assertSame('appended', $array['appendable']);
}

public function testToArrayWithCircularRelations()
{
$parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]);
$lastId = $parent->id;
$parent->setRelation('self', $parent);

$children = new Collection();
for ($count = 0; $count < 2; $count++) {
$child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]);
$child->setRelation('parent', $parent);
$child->setRelation('self', $child);
$children->push($child);
}
$parent->setRelation('children', $children);

try {
$this->assertSame(
[
'id' => 1,
'parent_id' => null,
'self' => ['id' => 1, 'parent_id' => null],
'children' => [
[
'id' => 2,
'parent_id' => 1,
'parent' => ['id' => 1, 'parent_id' => null],
'self' => ['id' => 2, 'parent_id' => 1],
],
[
'id' => 3,
'parent_id' => 1,
'parent' => ['id' => 1, 'parent_id' => null],
'self' => ['id' => 3, 'parent_id' => 1],
],
],
],
$parent->toArray()
);
} catch (\RuntimeException $e) {
$this->fail($e->getMessage());
}
}

public function testRelationsToArrayWithCircularRelations()
{
$parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]);
$lastId = $parent->id;
$parent->setRelation('self', $parent);

$children = new Collection();
for ($count = 0; $count < 2; $count++) {
$child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]);
$child->setRelation('parent', $parent);
$child->setRelation('self', $child);
$children->push($child);
}
$parent->setRelation('children', $children);

try {
$this->assertSame(
[
'self' => ['id' => 1, 'parent_id' => null],
'children' => [
[
'id' => 2,
'parent_id' => 1,
'parent' => ['id' => 1, 'parent_id' => null],
'self' => ['id' => 2, 'parent_id' => 1],
],
[
'id' => 3,
'parent_id' => 1,
'parent' => ['id' => 1, 'parent_id' => null],
'self' => ['id' => 3, 'parent_id' => 1],
],
],
],
$parent->relationsToArray()
);
} catch (\RuntimeException $e) {
$this->fail($e->getMessage());
}
}

public function testGetQueueableRelationsWithCircularRelations()
{
$parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]);
$lastId = $parent->id;
$parent->setRelation('self', $parent);

$children = new Collection();
for ($count = 0; $count < 2; $count++) {
$child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]);
$child->setRelation('parent', $parent);
$child->setRelation('self', $child);
$children->push($child);
}
$parent->setRelation('children', $children);

try {
$this->assertSame(
[
'self',
'children',
'children.parent',
'children.self',
],
$parent->getQueueableRelations()
);
} catch (\RuntimeException $e) {
$this->fail($e->getMessage());
}
}

public function testVisibleCreatesArrayWhitelist()
{
$model = new EloquentModelStub;
Expand Down Expand Up @@ -3683,3 +3819,95 @@ public function set(Model $model, string $key, mixed $value, array $attributes):
};
}
}

class EloquentModelWithRecursiveRelationshipsStub extends Model
{
public $fillable = ['id', 'parent_id'];

protected static \WeakMap $recursionDetectionCache;

public function getQueueableRelations()
{
try {
$this->stepIn();

return parent::getQueueableRelations();
} finally {
$this->stepOut();
}
}

public function push()
{
try {
$this->stepIn();

return parent::push();
} finally {
$this->stepOut();
}
}

public function save(array $options = [])
{
return true;
}

public function relationsToArray()
{
try {
$this->stepIn();

return parent::relationsToArray();
} finally {
$this->stepOut();
}
}

public function parent(): BelongsTo
{
return $this->belongsTo(static::class, 'parent_id');
}

public function children(): HasMany
{
return $this->hasMany(static::class, 'parent_id');
}

public function self(): BelongsTo
{
return $this->belongsTo(static::class, 'id');
}

protected static function getRecursionDetectionCache()
{
return static::$recursionDetectionCache ??= new \WeakMap;
}

protected function getRecursionDepth(): int
{
$cache = static::getRecursionDetectionCache();

return $cache->offsetExists($this) ? $cache->offsetGet($this) : 0;
}

protected function stepIn(): void
{
$depth = $this->getRecursionDepth();

if ($depth > 1) {
throw new \RuntimeException('Recursion detected');
}
static::getRecursionDetectionCache()->offsetSet($this, $depth + 1);
}

protected function stepOut(): void
{
$cache = static::getRecursionDetectionCache();
if ($depth = $this->getRecursionDepth()) {
$cache->offsetSet($this, $depth - 1);
} else {
$cache->offsetUnset($this);
}
}
}

0 comments on commit 287e737

Please sign in to comment.