Skip to content
Merged
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
35 changes: 35 additions & 0 deletions src/Data/AugmentedCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@
use Statamic\Contracts\Data\Augmentable;
use Statamic\Contracts\Query\Builder as StatamicQueryBuilder;
use Statamic\Fields\Value;
use Statamic\Fieldtypes\Link\ArrayableLink;

class AugmentedCollection extends Collection
{
protected $shallowNesting = false;
protected $shouldEvaluate = false;
protected $withRelations = [];

/**
* The linked items that are currently being converted to an array. A link
* back to one of them is represented shallowly, otherwise entries that link
* to each other would be converted to arrays forever.
*/
private static $expandingLinks = [];

/**
* Enables shallow augmentation on nested values when
* converting to array or JSON serializing.
Expand Down Expand Up @@ -80,6 +88,10 @@ public function toArray()
$value = $value->value();
}

if ($value instanceof ArrayableLink) {
return $this->linkToArray($value);
}

if ($this->isQueryBuilder($value)) {
$value = $value->get();
}
Expand Down Expand Up @@ -113,6 +125,29 @@ public function jsonSerialize(): array
}, $this->all());
}

private function linkToArray(ArrayableLink $link)
{
$item = $link->value();

if (! is_object($item)) {
return $link->toArray();
}

$reference = method_exists($item, 'reference') ? $item->reference() : spl_object_id($item);

if (in_array($reference, self::$expandingLinks, true)) {
return (new self($link->jsonSerialize()))->withEvaluation()->toArray();
}

self::$expandingLinks[] = $reference;

try {
return (new self($link->toArray()))->withEvaluation()->toArray();
} finally {
array_pop(self::$expandingLinks);
}
}

private function isQueryBuilder($value)
{
return $value instanceof StatamicQueryBuilder
Expand Down
126 changes: 126 additions & 0 deletions tests/Data/Entries/CyclicLinkTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace Tests\Data\Entries;

use Facades\Statamic\Fields\BlueprintRepository;
use Facades\Tests\Factories\EntryFactory;
use LogicException;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Blueprint;
use Statamic\Facades\Collection;
use Statamic\Fieldtypes\Link;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class CyclicLinkTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

protected function setUp(): void
{
parent::setUp();

CountingLink::$augmentations = 0;
CountingLink::register();

$blueprint = Blueprint::makeFromFields(['link' => ['type' => 'counting_link']]);
BlueprintRepository::shouldReceive('in')->with('collections/pages')->andReturn(collect(['pages' => $blueprint]));

Collection::make('pages')->routes('{slug}')->save();
}

private function page(string $id, ?string $linksTo = null, array $data = [])
{
return EntryFactory::id($id)
->collection('pages')
->slug($id)
->data(array_merge(['title' => strtoupper($id), 'link' => $linksTo ? "entry::$linksTo" : null], $data))
->create();
}

#[Test]
public function it_converts_entries_that_link_to_each_other_to_an_array()
{
$a = $this->page('a', 'b');
$this->page('b', 'a');

$array = $a->toArray();

$this->assertSame('B', $array['link']['title']);
$this->assertSame('A', $array['link']['link']['title']);

// A -> B -> A is expanded in full, but the link back to B is shallow.
$this->assertSame('B', $array['link']['link']['link']['title']);
$this->assertArrayNotHasKey('link', $array['link']['link']['link']);
}

#[Test]
public function it_converts_an_entry_that_links_to_itself_to_an_array()
{
$a = $this->page('a', 'a');

$array = $a->toArray();

$this->assertSame('A', $array['link']['title']);
}

#[Test]
public function it_converts_a_longer_cycle_to_an_array()
{
$a = $this->page('a', 'b');
$this->page('b', 'c');
$this->page('c', 'a');

$array = $a->toArray();

$this->assertSame('B', $array['link']['title']);
$this->assertSame('C', $array['link']['link']['title']);
}

#[Test]
public function it_keeps_the_full_data_of_linked_entries_without_a_cycle()
{
$a = $this->page('a', 'b');
$this->page('b', 'c', ['extra' => 'b-data']);
$this->page('c', null, ['extra' => 'c-data']);

$array = $a->toArray();

$this->assertSame('b-data', $array['link']['extra']);
$this->assertSame('c-data', $array['link']['link']['extra']);
}

#[Test]
public function it_converts_to_the_same_array_when_called_again()
{
$a = $this->page('a', 'b');
$this->page('b', 'a');

$this->assertEquals($a->toArray(), $a->toArray());
}
}

/**
* A link fieldtype that stops runaway augmentation. Without the cycle guard,
* entries that link to each other are augmented forever, which would exhaust
* the memory of the test run. Throwing after a few augmentations makes the
* tests fail with a clear message instead.
*/
class CountingLink extends Link
{
// The tests need at most 6 augmentations, this leaves some room.
private const MAX_AUGMENTATIONS = 25;

public static int $augmentations = 0;

protected static $handle = 'counting_link';

public function augment($value)
{
if (++self::$augmentations > self::MAX_AUGMENTATIONS) {
throw new LogicException('Links are being augmented recursively.');
}

return parent::augment($value);
}
}
Loading