diff --git a/src/Data/HasOrigin.php b/src/Data/HasOrigin.php index dde3e1fb736..186299cfad9 100644 --- a/src/Data/HasOrigin.php +++ b/src/Data/HasOrigin.php @@ -2,8 +2,13 @@ namespace Statamic\Data; +use Statamic\Facades\Blink; + trait HasOrigin { + /** + * @var string + */ protected $origin; public function keys() @@ -55,17 +60,30 @@ public function origin($origin = null) { return $this->fluentlyGetOrSet('origin') ->getter(function ($origin) { - if (is_string($origin)) { - $this->origin = $origin = $this->getOriginByString($origin); - } + return $origin + ? Blink::once($this->getOriginBlinkKey(), fn () => $this->getOriginByString($origin)) + : null; + }) + ->setter(function ($origin) { + Blink::forget($this->getOriginBlinkKey()); - return $origin; + return is_object($origin) ? $this->getOriginIdFromObject($origin) : $origin; }) ->args(func_get_args()); } abstract public function getOriginByString($origin); + protected function getOriginBlinkKey() + { + return 'origin-'.class_basename($this).'-'.$this->id(); + } + + protected function getOriginIdFromObject($origin) + { + return $origin->id(); + } + public function hasOrigin() { return $this->origin() !== null; diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index f6a9fd5b8ea..e72fe47f5e7 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -66,7 +66,6 @@ class Entry implements Contract, Augmentable, Responsable, Localization, Protect protected $blueprint; protected $date; protected $locale; - protected $localizations; protected $afterSaveCallbacks = []; protected $withEvents = true; protected $template; @@ -200,7 +199,7 @@ public function deleteDescendants() $entry->delete(); }); - $this->localizations = null; + Blink::forget('entry-descendants-'.$this->id()); return true; } @@ -327,8 +326,13 @@ public function save() if ($this->id()) { Blink::store('structure-uris')->forget($this->id()); Blink::store('structure-entries')->forget($this->id()); + Blink::forget($this->getOriginBlinkKey()); } + $this->ancestors()->each(fn ($entry) => Blink::forget('entry-descendants-'.$entry->id())); + + $this->directDescendants()->each->save(); + $this->taxonomize(); optional(Collection::findByMount($this))->updateEntryUris(); @@ -646,16 +650,33 @@ public function in($locale) return $this->descendants()->get($locale); } - public function descendants() + public function ancestors() { - if (! $this->localizations) { - $this->localizations = Facades\Entry::query() + $ancestors = collect(); + + $origin = $this->origin(); + + while ($origin) { + $ancestors->push($origin); + $origin = $origin->origin(); + } + + return $ancestors; + } + + public function directDescendants() + { + return Blink::once('entry-descendants-'.$this->id(), function () { + return Facades\Entry::query() ->where('collection', $this->collectionHandle()) ->where('origin', $this->id())->get() ->keyBy->locale(); - } + }); + } - $localizations = collect($this->localizations); + public function descendants() + { + $localizations = $this->directDescendants(); foreach ($localizations as $loc) { $localizations = $localizations->merge($loc->descendants()); @@ -669,12 +690,11 @@ public function existsIn($locale) return $this->in($locale) !== null; } + /** @deprecated */ public function addLocalization($entry) { $entry->origin($this); - $this->localizations[$entry->locale()] = $entry; - return $this; } diff --git a/src/Globals/Variables.php b/src/Globals/Variables.php index 6d991970c0c..36da6d8f8e9 100644 --- a/src/Globals/Variables.php +++ b/src/Globals/Variables.php @@ -229,6 +229,16 @@ protected function getOriginByString($origin) return $this->globalSet()->in($origin); } + protected function getOriginIdFromObject($origin) + { + return $origin->locale(); + } + + protected function getOriginBlinkKey() + { + return 'origin-globals-'.$this->id().'-'.$this->locale(); + } + public function newAugmentedInstance(): Augmented { return new AugmentedVariables($this); diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index edf3c53e468..00ffdce6511 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -61,7 +61,7 @@ protected function invalidateAssetUrls($asset) protected function invalidateEntryUrls($entry) { - $entry->descendants()->push($entry)->each(function ($entry) { + $entry->descendants()->merge([$entry])->each(function ($entry) { if (! $entry->isRedirect() && $url = $entry->absoluteUrl()) { $this->cacher->invalidateUrl(...$this->splitUrlAndDomain($url)); } diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 5d9fa2a331e..a1c92be9da8 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -24,6 +24,7 @@ use Statamic\Events\EntrySaved; use Statamic\Events\EntrySaving; use Statamic\Facades; +use Statamic\Facades\Blink; use Statamic\Fields\Blueprint; use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; @@ -32,6 +33,7 @@ use Statamic\Structures\CollectionTree; use Statamic\Structures\Page; use Statamic\Support\Arr; +use Statamic\Support\Str; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -293,8 +295,8 @@ public function it_merges_in_additional_data() public function values_fall_back_to_the_origin_then_the_collection() { $collection = tap(Collection::make('test'))->save(); - $origin = (new Entry)->collection('test'); - $entry = (new Entry)->origin($origin)->collection('test'); + $origin = EntryFactory::collection('test')->create(); + $entry = EntryFactory::origin($origin)->collection('test')->create(); $this->assertNull($entry->value('test')); @@ -314,14 +316,14 @@ public function it_gets_values_from_origin_and_collection() 'three' => 'three in collection', ]))->save(); - $origin = (new Entry)->collection('test')->data([ + $origin = EntryFactory::collection('test')->data([ 'two' => 'two in origin', 'three' => 'three in origin', - ]); + ])->create(); - $entry = (new Entry)->origin($origin)->collection('test')->data([ + $entry = EntryFactory::origin($origin)->collection('test')->data([ 'three' => 'three in entry', - ]); + ])->create(); $this->assertEquals([ 'one' => 'one in collection', @@ -344,16 +346,16 @@ public function if_the_value_is_explicitly_set_to_null_then_it_should_not_fall_b 'four' => 'four in collection', ]))->save(); - $origin = (new Entry)->collection('test')->data([ + $origin = EntryFactory::collection('test')->data([ 'two' => null, 'three' => 'three in origin', 'four' => 'four in origin', - ]); + ])->create(); - $entry = (new Entry)->origin($origin)->collection('test')->data([ + $entry = EntryFactory::origin($origin)->collection('test')->data([ 'three' => null, 'four' => 'four in entry', - ]); + ])->create(); $this->assertEquals([ 'one' => 'one in collection', // falls all the way back @@ -1172,8 +1174,8 @@ public function it_gets_the_blueprint_when_defined_in_an_origin_value() 'second' => $second = (new Blueprint)->setHandle('second'), ])); Collection::make('blog')->save(); - $origin = (new Entry)->collection('blog')->set('blueprint', 'second'); - $entry = (new Entry)->collection('blog')->origin($origin); + $origin = EntryFactory::collection('blog')->data(['blueprint' => 'second'])->create(); + $entry = EntryFactory::collection('blog')->origin($origin)->create(); $this->assertSame($second, $entry->blueprint()); $this->assertNotSame($first, $second); @@ -1187,8 +1189,8 @@ public function it_gets_the_blueprint_when_defined_in_an_origin_property() 'second' => $second = (new Blueprint)->setHandle('second'), ])); Collection::make('blog')->save(); - $origin = (new Entry)->collection('blog')->blueprint('second'); - $entry = (new Entry)->collection('blog')->origin($origin); + $origin = EntryFactory::collection('blog')->blueprint('second')->create(); + $entry = EntryFactory::collection('blog')->origin($origin)->create(); $this->assertSame($second, $entry->blueprint()); $this->assertNotSame($first, $second); @@ -1258,6 +1260,7 @@ public function it_saves_through_the_api() Facades\Entry::shouldReceive('save')->with($entry); Facades\Entry::shouldReceive('taxonomize')->with($entry); Facades\Entry::shouldReceive('find')->with('a')->once()->andReturnNull(); + Blink::put('entry-descendants-a', collect()); // Prevents the query needing to be mocked. $return = $entry->save(); @@ -1283,6 +1286,7 @@ public function it_dispatches_entry_created_only_once() Facades\Entry::shouldReceive('save')->with($entry); Facades\Entry::shouldReceive('taxonomize')->with($entry); Facades\Entry::shouldReceive('find')->with('1')->times(3)->andReturn(null, $entry, $entry); + Blink::put('entry-descendants-1', collect()); // Prevents the query needing to be mocked. $entry->save(); $entry->save(); @@ -1302,6 +1306,7 @@ public function it_saves_quietly() Facades\Entry::shouldReceive('save')->with($entry); Facades\Entry::shouldReceive('taxonomize')->with($entry); Facades\Entry::shouldReceive('find')->with('a')->once()->andReturnNull(); + Blink::put('entry-descendants-a', collect()); // Prevents the query needing to be mocked. $return = $entry->saveQuietly(); @@ -1352,7 +1357,7 @@ public function it_performs_callbacks_after_saving_but_before_the_saved_event_an Event::fake(); $collection = (new Collection)->handle('pages')->save(); - $entry = (new Entry)->id('a')->collection($collection); + $entry = EntryFactory::id('a')->collection($collection)->create(); Facades\Entry::shouldReceive('save')->with($entry); Facades\Entry::shouldReceive('taxonomize')->with($entry); Facades\Entry::shouldReceive('find')->with('a')->times(2)->andReturn(null, $entry); @@ -1725,8 +1730,8 @@ public function the_blueprint_is_added_to_the_localized_file_contents_if_explici public function it_gets_and_sets_the_template() { $collection = tap(Collection::make('test'))->save(); - $origin = (new Entry)->collection($collection); - $entry = (new Entry)->collection($collection)->origin($origin); + $origin = EntryFactory::collection($collection)->create(); + $entry = EntryFactory::collection($collection)->origin($origin)->create(); // defaults to default $this->assertEquals('default', $entry->template()); @@ -1769,8 +1774,8 @@ public function it_gets_and_sets_an_inferred_template_from_blueprint() public function it_gets_and_sets_the_layout() { $collection = tap(Collection::make('test'))->save(); - $origin = (new Entry)->collection($collection); - $entry = (new Entry)->collection($collection)->origin($origin); + $origin = EntryFactory::collection($collection)->create(); + $entry = EntryFactory::collection($collection)->origin($origin)->create(); // defaults to layout $this->assertEquals('layout', $entry->layout()); @@ -2097,5 +2102,139 @@ public function it_gets_preview_targets() ], $entryDe->previewTargets()->all()); } - // todo: add tests for localization things. in(), descendants(), addLocalization(), etc + /** @test */ + public function it_gets_all_descendants() + { + Facades\Site::setConfig(['default' => 'en', 'sites' => [ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'fr_CA' => ['locale' => 'fr_CA', 'url' => '/fr-ca/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + 'it' => ['local' => 'it_IT', 'url' => '/it/'], + ]]); + + $one = EntryFactory::collection('test')->id('1')->locale('en')->create(); + $two = EntryFactory::collection('test')->id('2')->origin('1')->locale('fr')->create(); + $three = EntryFactory::collection('test')->id('3')->origin('2')->locale('fr_CA')->create(); + $four = EntryFactory::collection('test')->id('4')->origin('2')->locale('de')->create(); + $five = EntryFactory::collection('test')->id('5')->origin('3')->locale('it')->create(); + + $this->assertEquals(['fr' => $two, 'fr_CA' => $three, 'de' => $four, 'it' => $five], $one->descendants()->all()); + $this->assertEquals(['fr_CA' => $three, 'de' => $four, 'it' => $five], $two->descendants()->all()); + $this->assertEquals(['it' => $five], $three->descendants()->all()); + $this->assertEquals([], $four->descendants()->all()); + } + + /** @test */ + public function it_gets_direct_descendants() + { + Facades\Site::setConfig(['default' => 'en', 'sites' => [ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'fr_CA' => ['locale' => 'fr_CA', 'url' => '/fr-ca/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + 'it' => ['local' => 'it_IT', 'url' => '/it/'], + ]]); + + $one = EntryFactory::collection('test')->id(1)->locale('en')->create(); + $two = EntryFactory::collection('test')->id(2)->origin(1)->locale('fr')->create(); + $three = EntryFactory::collection('test')->id(3)->origin(2)->locale('fr_CA')->create(); + $four = EntryFactory::collection('test')->id(4)->origin(2)->locale('de')->create(); + $five = EntryFactory::collection('test')->id(5)->origin(3)->locale('it')->create(); + + $this->assertEquals(['fr' => $two], $one->directDescendants()->all()); + $this->assertEquals(['fr_CA' => $three, 'de' => $four], $two->directDescendants()->all()); + $this->assertEquals(['it' => $five], $three->directDescendants()->all()); + $this->assertEquals([], $four->directDescendants()->all()); + } + + /** @test */ + public function it_gets_ancestors() + { + Facades\Site::setConfig(['default' => 'en', 'sites' => [ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'fr_CA' => ['locale' => 'fr_CA', 'url' => '/fr-ca/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + ]]); + + $one = EntryFactory::collection('test')->id('1')->locale('en')->create(); + $two = EntryFactory::collection('test')->id('2')->origin('1')->locale('fr')->create(); + $three = EntryFactory::collection('test')->id('3')->origin('2')->locale('fr_CA')->create(); + $four = EntryFactory::collection('test')->id('4')->origin('2')->locale('de')->create(); + + $this->assertEquals([], $one->ancestors()->all()); + $this->assertEquals([$one], $two->ancestors()->all()); + $this->assertEquals([$two, $one], $three->ancestors()->all()); + $this->assertEquals([$two, $one], $four->ancestors()->all()); + } + + // todo: add tests for localization things. in(), addLocalization(), etc + + /** @test */ + public function it_updates_the_origin_of_descendants_when_saving_an_entry_with_localizations() + { + // The issue this test is covering doesn't happen when using the + // array cache driver, since the objects are stored in memory. + config(['cache.default' => 'file']); + Cache::clear(); + + Facades\Site::setConfig([ + 'default' => 'en', + 'sites' => [ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => '/fr/'], + 'de' => ['name' => 'German', 'locale' => 'de_DE', 'url' => '/de/'], + ], + ]); + + $one = EntryFactory::collection('test')->id('1')->locale('en')->data(['foo' => 'root'])->create(); + $two = EntryFactory::collection('test')->id('2')->origin('1')->locale('fr')->create(); + $three = EntryFactory::collection('test')->id('3')->origin('2')->locale('de')->create(); + + // We want to check that the origin blink key was explicitly cleared, + // so we'll keep track of it happening from within the Entry@save method. + // It would also get cleared coincidentally within the Stache. + Blink::swap($fakeBlink = new class extends \Statamic\Support\Blink + { + public $calls = []; + + public function __call($method, $args) + { + // Ugly. Sorry. ¯\_(ツ)_/¯ + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + if ( + 'Statamic\Entries\Entry@save' === $trace[2]['class'].'@'.$trace[2]['function'] + && $method === 'forget' + && Str::startsWith($args[0], 'origin-Entry-') + ) { + $this->calls[$args[0]][] = true; + } + + return parent::__call($method, $args); + } + }); + + $this->assertEquals('root', $one->foo); + $this->assertEquals('root', $two->foo); + $this->assertEquals('root', $three->foo); + + $one->data(['foo' => 'root updated'])->save(); + + $this->assertEquals('root updated', $one->foo); + $this->assertEquals('root updated', $two->foo); + $this->assertEquals('root updated', $three->foo); + $this->assertCount(1, $fakeBlink->calls['origin-Entry-1']); + $this->assertCount(1, $fakeBlink->calls['origin-Entry-2']); + $this->assertCount(1, $fakeBlink->calls['origin-Entry-3']); + + $two->data(['foo' => 'two updated'])->save(); + + $this->assertEquals('root updated', $one->foo); + $this->assertEquals('two updated', $two->foo); + $this->assertEquals('two updated', $three->foo); + $this->assertCount(1, $fakeBlink->calls['origin-Entry-1']); + $this->assertCount(2, $fakeBlink->calls['origin-Entry-2']); + $this->assertCount(2, $fakeBlink->calls['origin-Entry-3']); + } } diff --git a/tests/Data/Globals/GlobalSetTest.php b/tests/Data/Globals/GlobalSetTest.php index 926051f7d8d..bf61d68f349 100644 --- a/tests/Data/Globals/GlobalSetTest.php +++ b/tests/Data/Globals/GlobalSetTest.php @@ -2,6 +2,7 @@ namespace Tests\Data\Globals; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; use Statamic\Events\GlobalSetCreated; use Statamic\Events\GlobalSetSaved; @@ -291,4 +292,46 @@ public function if_saving_event_returns_false_the_global_set_doesnt_save() Event::assertNotDispatched(GlobalSetSaved::class); } + + /** @test */ + public function it_updates_the_origin_of_descendants_when_saving_an_entry_with_localizations() + { + // The issue this test is covering doesn't happen when using the + // array cache driver, since the objects are stored in memory. + config(['cache.default' => 'file']); + Cache::clear(); + + Site::setConfig([ + 'default' => 'en', + 'sites' => [ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => '/fr/'], + 'de' => ['name' => 'German', 'locale' => 'de_DE', 'url' => '/de/'], + ], + ]); + + $global = tap(GlobalSet::make('test'), function ($global) { + $global->addLocalization($global->makeLocalization('en')->data(['foo' => 'root'])); + $global->addLocalization($global->makeLocalization('fr')->origin('en')); + $global->addLocalization($global->makeLocalization('de')->origin('fr')); + })->save(); + + $this->assertEquals('root', $global->in('en')->foo); + $this->assertEquals('root', $global->in('fr')->foo); + $this->assertEquals('root', $global->in('de')->foo); + + $global = GlobalSet::find('test'); + $global->in('en')->data(['foo' => 'root updated'])->save(); + + $this->assertEquals('root updated', $global->in('en')->foo); + $this->assertEquals('root updated', $global->in('fr')->foo); + $this->assertEquals('root updated', $global->in('de')->foo); + + $global = GlobalSet::find('test'); + $global->in('fr')->data(['foo' => 'fr updated'])->save(); + + $this->assertEquals('root updated', $global->in('en')->foo); + $this->assertEquals('fr updated', $global->in('fr')->foo); + $this->assertEquals('fr updated', $global->in('de')->foo); + } } diff --git a/tests/Data/Globals/VariablesTest.php b/tests/Data/Globals/VariablesTest.php index 259e49ba8ba..22a82d8511d 100644 --- a/tests/Data/Globals/VariablesTest.php +++ b/tests/Data/Globals/VariablesTest.php @@ -9,18 +9,36 @@ use Statamic\Facades; use Statamic\Facades\Blueprint; use Statamic\Facades\GlobalSet; +use Statamic\Facades\Site; use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; use Statamic\Globals\Variables; use Statamic\Support\Arr; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class VariablesTest extends TestCase { + use PreventSavingStacheItemsToDisk; + + public function setUp(): void + { + parent::setUp(); + + Site::setConfig(['sites' => [ + 'a' => ['url' => '/', 'locale' => 'en'], + 'b' => ['url' => '/b/', 'locale' => 'fr'], + 'c' => ['url' => '/b/', 'locale' => 'fr'], + 'd' => ['url' => '/d/', 'locale' => 'fr'], + ]]); + } + /** @test */ public function it_gets_file_contents_for_saving() { - $entry = (new Variables)->data([ + $global = GlobalSet::make('test'); + + $entry = $global->makeLocalization('a')->data([ 'array' => ['first one', 'second one'], 'string' => 'The string', 'null' => null, // this... @@ -63,6 +81,10 @@ public function it_gets_file_contents_for_saving_a_localized_set() 'empty' => [], // and this should get stripped out because there's no origin to fall back to. ]); + $global->addLocalization($a); + $global->addLocalization($b); + $global->addLocalization($c); + $expected = <<<'EOT' array: - 'first one' @@ -130,6 +152,12 @@ public function if_the_value_is_explicitly_set_to_null_then_it_should_not_fall_b 'two' => null, ]); + $global->addLocalization($a); + $global->addLocalization($b); + $global->addLocalization($c); + $global->addLocalization($d); + $global->addLocalization($e); + $this->assertEquals([ 'one' => 'alfa', 'two' => 'bravo', diff --git a/tests/Factories/EntryFactory.php b/tests/Factories/EntryFactory.php index 306a6ebee13..d1efd30a52f 100644 --- a/tests/Factories/EntryFactory.php +++ b/tests/Factories/EntryFactory.php @@ -17,6 +17,7 @@ class EntryFactory protected $locale; protected $origin; protected $collection; + protected $blueprint; public function __construct() { @@ -79,6 +80,13 @@ public function origin($origin) return $this; } + public function blueprint($blueprint) + { + $this->blueprint = $blueprint; + + return $this; + } + public function make() { $entry = Entry::make() @@ -87,7 +95,8 @@ public function make() ->slug($this->slug) ->data($this->data) ->origin($this->origin) - ->published($this->published); + ->published($this->published) + ->blueprint($this->blueprint); if ($collection->dated()) { $entry->date($this->date); @@ -130,5 +139,6 @@ private function reset() $this->locale = 'en'; $this->origin = null; $this->collection = null; + $this->blueprint = null; } } diff --git a/tests/Modifiers/PluckTest.php b/tests/Modifiers/PluckTest.php index 2a9f429d5fc..1b5ab248325 100644 --- a/tests/Modifiers/PluckTest.php +++ b/tests/Modifiers/PluckTest.php @@ -188,12 +188,18 @@ class ItemWithOrigin public function __construct($data, $origin = null) { $this->data($data); - $this->origin($origin); + $this->origin = $origin; + } + + public function origin($origin = null) + { + // Bypass the logic to load the origin. Just use what was passed in. + return $this->origin; } public function getOriginByString($origin) { - // + // Required by trait } } diff --git a/tests/Routing/UrlBuilderTest.php b/tests/Routing/UrlBuilderTest.php index 74c73550d61..76eb96a0f09 100644 --- a/tests/Routing/UrlBuilderTest.php +++ b/tests/Routing/UrlBuilderTest.php @@ -30,7 +30,7 @@ public function setUp(): void 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR'], ]]); - $entry = \Statamic\Facades\Entry::make() + $entry = tap(\Statamic\Facades\Entry::make() ->id('post') ->locale('en') ->collection( @@ -38,11 +38,10 @@ public function setUp(): void ) ->slug('post') ->date('2015-01-02') - ->data(['foo' => 'bar', 'slashed' => 'foo/bar']); + ->data(['foo' => 'bar', 'slashed' => 'foo/bar']) + )->save(); - $entry->addLocalization( - $entry->makeLocalization('fr')->slug('le-post') - ); + $entry->makeLocalization('fr')->slug('le-post')->save(); $this->builder = app(UrlBuilder::class)->content($entry); $this->entry = $entry;