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
83 changes: 81 additions & 2 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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']));
}
}
115 changes: 115 additions & 0 deletions tests/Data/Entries/EntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading