Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/Data/HasOrigin.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,28 @@ public function isRoot()

public function root()
{
$entry = $this;
$chain = $this->originChain();

while ($entry->hasOrigin()) {
$entry = $entry->origin();
return empty($chain) ? $this : end($chain);
}

protected function originChain()
{
$chain = [];
$seen = [$this->originChainKey($this)];
$item = $this->origin();

while ($item && ! in_array($key = $this->originChainKey($item), $seen, true)) {
$chain[] = $item;
$seen[] = $key;
$item = $item->origin();
}

return $entry;
return $chain;
}

private function originChainKey($item)
{
return method_exists($item, 'id') && ($id = $item->id()) ? $id : spl_object_id($item);
}
}
23 changes: 16 additions & 7 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use Statamic\Events\EntrySaved;
use Statamic\Events\EntrySaving;
use Statamic\Exceptions\BlueprintNotFoundException;
use Statamic\Exceptions\EntryOriginRecursionException;
use Statamic\Facades;
use Statamic\Facades\Antlers;
use Statamic\Facades\Blink;
Expand Down Expand Up @@ -235,7 +236,7 @@ public function getCurrentDirtyStateAttributes(): array
return array_merge([
'collection' => $this->collectionHandle(),
'locale' => $this->locale(),
'origin' => $this->hasOrigin() ? $this->origin()->id() : null,
'origin' => $this->origin,
'slug' => $this->slug(),
'date' => optional($this->date())->format('Y-m-d-Hi'),
'published' => $this->published(),
Expand Down Expand Up @@ -399,6 +400,8 @@ public function saveQuietly()

public function save()
{
$this->ensureOriginIsNotRecursive();

$isNew = is_null(Facades\Entry::find($this->id()));

$withEvents = $this->withEvents;
Expand Down Expand Up @@ -847,16 +850,22 @@ public function in($locale)

public function ancestors()
{
$ancestors = collect();
return collect($this->originChain());
}

$origin = $this->origin();
private function ensureOriginIsNotRecursive()
{
if (! $this->origin) {
return;
}

while ($origin) {
$ancestors->push($origin);
$origin = $origin->origin();
$last = collect($this->originChain())->last() ?? $this;

if ($last->origin() === null) {
return;
}

return $ancestors;
throw new EntryOriginRecursionException($this->id(), $this->origin);
}

public function directDescendants()
Expand Down
32 changes: 32 additions & 0 deletions src/Exceptions/EntryOriginRecursionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Statamic\Exceptions;

use Exception;
use Spatie\ErrorSolutions\Contracts\BaseSolution;
use Spatie\ErrorSolutions\Contracts\ProvidesSolution;
use Spatie\ErrorSolutions\Contracts\Solution;

class EntryOriginRecursionException extends Exception implements ProvidesSolution
{
public function __construct(private ?string $entry, private ?string $origin)
{
parent::__construct("Entry [$entry] cannot use origin [$origin] because it would create a loop.");
}

public function getEntry()
{
return $this->entry;
}

public function getOrigin()
{
return $this->origin;
}

public function getSolution(): Solution
{
return BaseSolution::create('Avoid infinite recursion')
->setSolutionDescription("The entry `$this->origin` already originates from `$this->entry`, directly or through another entry. Pick an origin that isn't a descendant of this entry.");
}
}
114 changes: 114 additions & 0 deletions tests/Data/Entries/EntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Statamic\Events\EntryDeleting;
use Statamic\Events\EntrySaved;
use Statamic\Events\EntrySaving;
use Statamic\Exceptions\EntryOriginRecursionException;
use Statamic\Facades;
use Statamic\Facades\Blink;
use Statamic\Fields\Blueprint;
Expand Down Expand Up @@ -1655,6 +1656,109 @@ public function it_includes_descendants_nested_via_an_origin_chain()
$this->assertSame($de, $descendants->get('de'));
}

#[Test]
public function the_root_of_an_entry_in_an_origin_cycle_does_not_recurse()
{
$collection = tap(Collection::make('test'))->save();

$a = (new CyclicOriginEntry)->id('a')->locale('en')->collection($collection);
$b = (new CyclicOriginEntry)->id('b')->locale('fr')->collection($collection);

CyclicOriginEntry::$entries = ['a' => $a, 'b' => $b];

$a->origin('b');
$b->origin('a');

// The walk stops where the cycle closes rather than looping forever.
$this->assertSame($b, $a->root());
$this->assertSame($a, $b->root());
}

#[Test]
public function it_walks_a_valid_origin_chain_to_the_root()
{
$collection = tap(Collection::make('test'))->save();

$a = (new CyclicOriginEntry)->id('a')->locale('en')->collection($collection);
$b = (new CyclicOriginEntry)->id('b')->locale('fr')->collection($collection);
$c = (new CyclicOriginEntry)->id('c')->locale('de')->collection($collection);

CyclicOriginEntry::$entries = ['a' => $a, 'b' => $b, 'c' => $c];

// a -> b -> c, no cycle.
$a->origin('b');
$b->origin('c');

$this->assertSame($c, $a->root());
$this->assertSame([$b, $c], $a->ancestors()->all());
}

#[Test]
public function it_cannot_be_saved_with_an_origin_that_points_back_at_it()
{
tap(Collection::make('test')->sites(['en', 'fr']))->save();

$a = EntryFactory::id('a')->collection('test')->locale('en')->create();
$b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('a')->create();

$this->expectException(EntryOriginRecursionException::class);
$this->expectExceptionMessage('Entry [a] cannot use origin [b] because it would create a loop.');

$a->origin($b)->save();
}

#[Test]
public function it_cannot_be_saved_with_an_origin_further_up_its_own_chain()
{
tap(Collection::make('test')->sites(['en', 'fr', 'de']))->save();

$a = EntryFactory::id('a')->collection('test')->locale('en')->create();
$b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('a')->create();
$c = EntryFactory::id('c')->collection('test')->locale('de')->origin('b')->create();

$this->expectException(EntryOriginRecursionException::class);

$a->origin($c)->save();
}

#[Test]
public function it_cannot_be_saved_with_itself_as_origin()
{
tap(Collection::make('test')->sites(['en']))->save();

$a = EntryFactory::id('a')->collection('test')->locale('en')->create();

$this->expectException(EntryOriginRecursionException::class);

$a->origin('a')->save();
}

#[Test]
public function it_can_be_saved_when_an_origin_further_up_the_chain_no_longer_exists()
{
tap(Collection::make('test')->sites(['en', 'fr', 'de']))->save();

$b = EntryFactory::id('b')->collection('test')->locale('fr')->origin('missing')->create();
$c = EntryFactory::id('c')->collection('test')->locale('de')->create();

$c->origin($b)->save();

$this->assertSame('b', $c->origin()->id());
}

#[Test]
public function it_can_be_saved_with_an_origin_that_does_not_loop()
{
tap(Collection::make('test')->sites(['en', 'fr']))->save();

$a = EntryFactory::id('a')->collection('test')->locale('en')->create();
$b = EntryFactory::id('b')->collection('test')->locale('fr')->create();

$b->origin($a)->save();

$this->assertSame($a->id(), $b->origin()->id());
}

private function fakeDescendantsQuery($results, ?array $whereInOrigins = null): QueryBuilder
{
$query = Mockery::mock(QueryBuilder::class);
Expand Down Expand Up @@ -2955,6 +3059,16 @@ public function entries_can_be_serialized_after_resolving_values()
}
}

class CyclicOriginEntry extends Entry
{
public static $entries = [];

protected function getOriginByString($origin)
{
return static::$entries[$origin] ?? null;
}
}

class CustomEntry extends Entry
{
public static function fromEntry(Entry $entry)
Expand Down
Loading