From bb54611a1aec2151461f4e10ec699a69ae4ee553 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 18:51:31 +0000 Subject: [PATCH] Work out an entry's original state only when it's asked for --- src/Entries/Entry.php | 83 +++++++++++++++++++++- tests/Data/Entries/EntryTest.php | 115 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 44fbe5c51c4..42dddad0e35 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -58,7 +58,10 @@ class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, Con { use ContainsComputedData, ContainsData, ExistsAsFile, FluentlyGetsAndSets, HasAugmentedInstance, Localizable, Publishable, Revisable, Searchable, TracksLastModified, TracksQueriedColumns, TracksQueriedRelations; - use HasDirtyState; + use HasDirtyState { + isDirty as traitIsDirty; + getOriginal as traitGetOriginal; + } use HasOrigin { value as originValue; values as originValues; @@ -83,6 +86,12 @@ class Entry implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, Con private $computedCallbackCache; private $siteCache; + /** + * This entry as it was when its original state was last synced, kept until + * something asks what the original state was. + */ + private $originalEntry; + public function __construct() { $this->data = collect(); @@ -230,6 +239,74 @@ public function newAugmentedInstance(): Augmented return new AugmentedEntry($this); } + /** + * The original state is synced every time an entry is read from the Stache, but + * is rarely asked for. Rather than working out every attribute each time, a copy + * of the entry is kept, and the attributes are taken from it when they're needed. + */ + public function syncOriginal(): static + { + $this->originalEntry = null; + + $entry = clone $this; + + // Dates can be modified in place, so the copy needs its own. + if ($entry->date instanceof \DateTimeInterface) { + $entry->date = clone $entry->date; + } + + $this->originalEntry = $entry; + + return $this; + } + + public function isDirty($attributes = null): bool + { + $this->resolveOriginal(); + + return $this->traitIsDirty($attributes); + } + + public function getOriginal($key = null, $fallback = null) + { + $this->resolveOriginal(); + + return $this->traitGetOriginal($key, $fallback); + } + + private function resolveOriginal(): void + { + if (! $entry = $this->originalEntry) { + return; + } + + $this->originalEntry = null; + + if (! $entry->origin) { + $this->original = $entry->getCurrentDirtyStateAttributes(); + + return; + } + + // Origins are remembered by entry id, so the copy would be given this entry's + // origin as it is now. It needs the origin it had when it was synced, and + // whatever was remembered has to be put back afterwards. + $key = $entry->getOriginBlinkKey(); + $remembered = Blink::get($key); + + if ($origin = $entry->getOriginByString($entry->origin)) { + Blink::put($key, $origin); + } else { + Blink::forget($key); + } + + try { + $this->original = $entry->getCurrentDirtyStateAttributes(); + } finally { + $remembered ? Blink::put($key, $remembered) : Blink::forget($key); + } + } + public function getCurrentDirtyStateAttributes(): array { return array_merge([ @@ -1272,6 +1349,8 @@ public function __sleep() $this->slug = $slug($this); } - return array_keys(Arr::except(get_object_vars($this), ['cachedKeys', 'computedCallbackCache', 'siteCache', 'augmentationReferenceKey'])); + $this->resolveOriginal(); + + return array_keys(Arr::except(get_object_vars($this), ['cachedKeys', 'computedCallbackCache', 'siteCache', 'augmentationReferenceKey', 'originalEntry'])); } } diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 5c277af174f..7f1b96b3a34 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -2866,6 +2866,121 @@ public function it_has_a_dirty_state() $this->assertFalse($entry->isClean(['title', 'food'])); } + #[Test] + public function it_only_works_out_the_original_state_when_asked() + { + $collection = tap(Collection::make('test'))->save(); + + $entry = new class extends Entry + { + public $timesWorkedOut = 0; + + public function getCurrentDirtyStateAttributes(): array + { + $this->timesWorkedOut++; + + return parent::getCurrentDirtyStateAttributes(); + } + }; + + $entry->collection($collection)->locale('en')->id('en')->slug('test')->data(['title' => 'English']); + + $entry->syncOriginal(); + $entry->syncOriginal(); + $this->assertSame(0, $entry->timesWorkedOut); + + $this->assertSame('English', $entry->getOriginal('title')); + $this->assertSame('test', $entry->getOriginal('slug')); + $this->assertSame(0, $entry->timesWorkedOut, 'The original state should come from the copy, not this entry.'); + + $this->assertFalse($entry->isDirty()); + $this->assertSame(1, $entry->timesWorkedOut, 'Only the current state should have been worked out.'); + } + + #[Test] + public function the_original_state_is_what_it_was_when_synced_whatever_changes_afterwards() + { + $collection = tap(Collection::make('test')->dated(true))->save(); + $entry = (new Entry)->collection($collection)->locale('en')->id('en')->slug('original-slug') + ->date('2024-05-06')->published(true)->data(['title' => 'Original', 'food' => 'Burger']); + + $entry->syncOriginal(); + + $entry->set('title', 'Changed'); + $entry->data()->put('food', 'Pizza'); + $entry->slug('changed-slug'); + $entry->published(false); + $entry->date()->addDays(3); + + $this->assertSame('Original', $entry->getOriginal('title')); + $this->assertSame('Burger', $entry->getOriginal('food')); + $this->assertSame('original-slug', $entry->getOriginal('slug')); + $this->assertTrue($entry->getOriginal('published')); + $this->assertSame('2024-05-06-0000', $entry->getOriginal('date')); + + $this->assertTrue($entry->isDirty(['title', 'food', 'slug', 'published', 'date'])); + $this->assertTrue($entry->isDirty('title')); + $this->assertTrue($entry->isDirty('date')); + $this->assertFalse($entry->isDirty('locale')); + } + + #[Test] + public function the_original_state_keeps_the_origin_it_was_synced_with() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $collection = tap(Collection::make('test')->dated(true)->sites(['en', 'fr']))->save(); + $one = EntryFactory::collection('test')->id('one')->locale('en')->slug('one')->date('2024-01-01')->create(); + $two = EntryFactory::collection('test')->id('two')->locale('en')->slug('two')->date('2024-02-02')->create(); + EntryFactory::collection('test')->id('fr')->locale('fr')->slug('fr')->origin('one')->create(); + + // The localization has no date of its own, so its date comes from its origin. + $fr = Facades\Entry::find('fr'); + (fn () => $this->date = null)->call($fr); + $fr->syncOriginal(); + + $fr->origin($two); + + $this->assertSame('one', $fr->getOriginal('origin')); + $this->assertSame('2024-01-01-0000', $fr->getOriginal('date')); + $this->assertTrue($fr->isDirty('origin')); + $this->assertSame('two', $fr->origin()->id(), 'Working out the original state should not change the current origin.'); + + $fr->syncOriginal(); + $fr->origin(null); + + $this->assertSame('two', $fr->getOriginal('origin')); + $this->assertNull($fr->origin()); + $this->assertTrue($fr->isDirty('origin')); + } + + #[Test] + public function the_original_state_survives_serialization_and_cloning() + { + $collection = tap(Collection::make('test'))->save(); + $entry = (new Entry)->collection($collection)->locale('en')->id('en')->slug('test')->data(['title' => 'Original']); + $entry->syncOriginal(); + + $this->assertNotContains('originalEntry', $entry->__sleep()); + + $unserialized = unserialize(serialize($entry)); + $unserialized->set('title', 'Changed'); + $this->assertSame('Original', $unserialized->getOriginal('title')); + $this->assertTrue($unserialized->isDirty('title')); + + $entry->syncOriginal(); + $clone = clone $entry; + $clone->set('title', 'Changed on the clone'); + + $this->assertSame('Original', $clone->getOriginal('title')); + $this->assertTrue($clone->isDirty('title')); + $this->assertSame('Original', $entry->getOriginal('title')); + $this->assertFalse($entry->isDirty()); + } + #[Test] public function it_syncs_original_at_the_right_time() {