diff --git a/src/Data/AugmentedCollection.php b/src/Data/AugmentedCollection.php index a7acb624b51..1c630e649d2 100644 --- a/src/Data/AugmentedCollection.php +++ b/src/Data/AugmentedCollection.php @@ -10,6 +10,7 @@ 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 { @@ -17,6 +18,13 @@ class AugmentedCollection extends Collection 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. @@ -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(); } @@ -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 diff --git a/tests/Data/Entries/CyclicLinkTest.php b/tests/Data/Entries/CyclicLinkTest.php new file mode 100644 index 00000000000..0bc15e02b0a --- /dev/null +++ b/tests/Data/Entries/CyclicLinkTest.php @@ -0,0 +1,126 @@ + ['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); + } +}