diff --git a/src/Version.php b/src/Version.php index 65dc68e..f8c9e31 100644 --- a/src/Version.php +++ b/src/Version.php @@ -2,8 +2,10 @@ namespace Overtrue\LaravelVersionable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; /** * @property Model|\Overtrue\LaravelVersionable\Versionable $versionable @@ -50,9 +52,12 @@ public function versionable(): \Illuminate\Database\Eloquent\Relations\MorphTo /** * @param \Illuminate\Database\Eloquent\Model $model * @param array $attributes + * @param string|DateTimeInterface|null $time * @return \Overtrue\LaravelVersionable\Version + * + * @throws \Carbon\Exceptions\InvalidFormatException */ - public static function createForModel(Model $model, array $attributes = []): Version + public static function createForModel(Model $model, array $attributes = [], $time = null): Version { /* @var \Overtrue\LaravelVersionable\Versionable|Model $model */ $versionClass = $model->getVersionModel(); @@ -64,7 +69,11 @@ public static function createForModel(Model $model, array $attributes = []): Ver $version->versionable_id = $model->getKey(); $version->versionable_type = $model->getMorphClass(); $version->{\config('versionable.user_foreign_key')} = $model->getVersionUserId(); - $version->contents = \array_merge($attributes, $model->getVersionableAttributes()); + $version->contents = $model->getVersionableAttributes($attributes); + + if ($time) { + $version->created_at = Carbon::parse($time); + } $version->save(); @@ -81,19 +90,46 @@ public function revertWithoutSaving(): ?Model return $this->versionable->forceFill($this->contents); } + public function scopeOrderOldestFirst(Builder $query): Builder + { + return $query->oldest()->oldest('id'); + } + + public function scopeOrderLatestFirst(Builder $query): Builder + { + return $query->latest()->latest('id'); + } + public function previousVersion(): ?static { - return $this->versionable->versions()->where('id', '<', $this->id)->latest('id')->first(); + return $this->versionable->history() + ->where(function ($query) { + $query->where('created_at', '<', $this->created_at) + ->orWhere(function ($query) { + $query->where('id', '<', $this->getKey()) + ->where('created_at', '<=', $this->created_at); + }); + }) + ->first(); } public function nextVersion(): ?static { - return $this->versionable->versions()->where('id', '>', $this->id)->oldest('id')->first(); + return $this->versionable->versions() + ->where(function ($query) { + $query->where('created_at', '>', $this->created_at) + ->orWhere(function ($query) { + $query->where('id', '>', $this->getKey()) + ->where('created_at', '>=', $this->created_at); + }); + }) + ->orderOldestFirst() + ->first(); } public function diff(Version $toVersion = null, array $differOptions = [], array $renderOptions = []): Diff { - if (! $toVersion) { + if (!$toVersion) { $toVersion = $this->previousVersion() ?? new static(); } diff --git a/src/Versionable.php b/src/Versionable.php index 104fe2c..a006b73 100644 --- a/src/Versionable.php +++ b/src/Versionable.php @@ -21,7 +21,7 @@ public static function bootVersionable() { static::saved( function (Model $model) { - static::createVersionForModel($model); + $model->autoCreateVersion(); } ); @@ -31,19 +31,37 @@ function (Model $model) { if ($model->forceDeleting) { $model->forceRemoveAllVersions(); } else { - static::createVersionForModel($model); + $model->autoCreateVersion(); } } ); } - private static function createVersionForModel(Model $model): void + private function autoCreateVersion(): ?Version { - /* @var \Overtrue\LaravelVersionable\Versionable|Model $model */ - if (static::$versioning && $model->shouldVersioning()) { - Version::createForModel($model); - $model->removeOldVersions($model->getKeepVersionsCount()); + if (static::$versioning) { + return $this->createVersion(); } + + return null; + } + + /** + * @param array $attributes + * @param string|DateTimeInterface|null $time + * @return ?Version + * + * @throws \Carbon\Exceptions\InvalidFormatException + */ + public function createVersion(array $attributes = [], $time = null): ?Version + { + if ($this->shouldBeVersioning() || !empty($attributes)) { + return tap(Version::createForModel($this, $attributes, $time), function () { + $this->removeOldVersions($this->getKeepVersionsCount()); + }); + } + + return null; } public function versions(): MorphMany @@ -51,6 +69,11 @@ public function versions(): MorphMany return $this->morphMany($this->getVersionModel(), 'versionable'); } + public function history(): MorphMany + { + return $this->versions()->orderLatestFirst(); + } + public function lastVersion(): MorphOne { return $this->latestVersion(); @@ -58,12 +81,12 @@ public function lastVersion(): MorphOne public function latestVersion(): MorphOne { - return $this->morphOne($this->getVersionModel(), 'versionable')->latest('id'); + return $this->morphOne($this->getVersionModel(), 'versionable')->orderLatestFirst(); } public function firstVersion(): MorphOne { - return $this->morphOne($this->getVersionModel(), 'versionable')->oldest('id'); + return $this->morphOne($this->getVersionModel(), 'versionable')->orderOldestFirst(); } /** @@ -77,10 +100,8 @@ public function firstVersion(): MorphOne */ public function versionAt($time = null, $tz = null): ?Version { - return $this->versions() + return $this->history() ->where('created_at', '<=', Carbon::parse($time, $tz)) - ->orderByDesc('created_at') - ->orderByDesc($this->getKey()) ->first(); } @@ -110,7 +131,7 @@ public function removeOldVersions(int $keep = 1): void return; } - $this->versions()->skip($keep)->take(PHP_INT_MAX)->get()->each->delete(); + $this->history()->skip($keep)->take(PHP_INT_MAX)->get()->each->delete(); } public function removeVersions(array $ids) @@ -155,28 +176,28 @@ public function forceRemoveAllVersions(): void $this->versions->each->forceDelete(); } - public function shouldVersioning(): bool + public function shouldBeVersioning(): bool { - return ! empty($this->getVersionableAttributes()); + return !empty($this->getVersionableAttributes()); } - public function getVersionableAttributes(): array + public function getVersionableAttributes(array $attributes = []): array { $changes = $this->getDirty(); - if (empty($changes)) { + if (empty($changes) && empty($attributes)) { return []; } $changes = $this->versionableFromArray($changes); $changedKeys = array_keys($changes); - if ($this->getVersionStrategy() === VersionStrategy::SNAPSHOT && ! empty($changes)) { + if ($this->getVersionStrategy() === VersionStrategy::SNAPSHOT && (!empty($changes) || !empty($attributes))) { $changedKeys = array_keys($this->getAttributes()); } // to keep casts and mutators works, we need to get the updated attributes from the model - return $this->only($changedKeys); + return \array_merge($this->only($changedKeys), $attributes); } /** @@ -184,7 +205,7 @@ public function getVersionableAttributes(): array */ public function setVersionable(array $attributes): static { - if (! \property_exists($this, 'versionable')) { + if (!\property_exists($this, 'versionable')) { throw new \Exception('Property $versionable not exist.'); } @@ -198,7 +219,7 @@ public function setVersionable(array $attributes): static */ public function setDontVersionable(array $attributes): static { - if (! \property_exists($this, 'dontVersionable')) { + if (!\property_exists($this, 'dontVersionable')) { throw new \Exception('Property $dontVersionable not exist.'); } @@ -227,7 +248,7 @@ public function getVersionStrategy(): string */ public function setVersionStrategy(string $strategy): static { - if (! \property_exists($this, 'versionStrategy')) { + if (!\property_exists($this, 'versionStrategy')) { throw new \Exception('Property $versionStrategy not exist.'); } diff --git a/tests/FeatureTest.php b/tests/FeatureTest.php index e5f6e86..1f6290d 100644 --- a/tests/FeatureTest.php +++ b/tests/FeatureTest.php @@ -2,7 +2,9 @@ namespace Tests; +use Illuminate\Support\Carbon; use Overtrue\LaravelVersionable\Diff; +use Overtrue\LaravelVersionable\Version; use Overtrue\LaravelVersionable\VersionStrategy; class FeatureTest extends TestCase @@ -146,6 +148,87 @@ public function user_can_get_diff_of_version() $this->assertSame(['title' => ['old' => 'version1', 'new' => 'version2']], $post->lastVersion->diff()->toArray()); } + /** + * @test + */ + public function user_can_get_previous_version() + { + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + $post->update(['title' => 'version2']); + $post->update(['title' => 'version3']); + + $post->refresh(); + + $this->assertEquals('version3', $post->latestVersion->contents['title']); + $this->assertEquals('version2', $post->latestVersion->previousVersion()->contents['title']); + $this->assertEquals('version1', $post->latestVersion->previousVersion()->previousVersion()->contents['title']); + $this->assertNull($post->latestVersion->previousVersion()->previousVersion()->previousVersion()); + } + + /** + * @test + */ + public function user_can_get_next_version() + { + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + $post->update(['title' => 'version2']); + $post->update(['title' => 'version3']); + + $post->refresh(); + + $this->assertEquals('version1', $post->firstVersion->contents['title']); + $this->assertEquals('version2', $post->firstVersion->nextVersion()->contents['title']); + $this->assertEquals('version3', $post->firstVersion->nextVersion()->nextVersion()->contents['title']); + $this->assertNull($post->firstVersion->nextVersion()->nextVersion()->nextVersion()); + } + + /** + * @test + */ + public function previous_versions_created_later_on_will_have_correct_order() + { + $this->travelTo(Carbon::create(2022, 10, 2, 14, 0)); + + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + $post->update(['title' => 'version2']); + + $this->travelTo(Carbon::create(2022, 10, 2, 15, 0)); + $post->update(['title' => 'version5']); + + $post->refresh(); + + $post->title = 'version4'; + $post->createVersion([], Carbon::create(2022, 10, 2, 14, 30)); + $post->createVersion(['title' => 'version3'], Carbon::create(2022, 10, 2, 14, 0)); + + $post->refresh(); + + $this->assertEquals('version5', $post->title); + $this->assertEquals('version5', $post->latestVersion->contents['title']); + $this->assertEquals('version4', $post->latestVersion->previousVersion()->contents['title']); + $this->assertEquals('version3', $post->latestVersion->previousVersion()->previousVersion()->contents['title']); + $this->assertEquals('version2', $post->latestVersion->previousVersion()->previousVersion()->previousVersion()->contents['title']); + $this->assertEquals('version1', $post->latestVersion->previousVersion()->previousVersion()->previousVersion()->previousVersion()->contents['title']); + $this->assertNull($post->latestVersion->previousVersion()->previousVersion()->previousVersion()->previousVersion()->previousVersion()); + } + + /** + * @test + */ + public function user_can_get_ordered_history() + { + $post = Post::create(['title' => 'version2', 'content' => 'version2 content']); + $post->update(['title' => 'version3']); + $post->update(['title' => 'version4']); + + $post->createVersion(['title' => 'version1'], Carbon::now()->subDay(1)); + + $this->assertEquals( + ['version4', 'version3', 'version2', 'version1'], + $post->history->pluck('contents.title')->toArray(), + ); + } + /** * @test */ diff --git a/tests/ManualVersionTest.php b/tests/ManualVersionTest.php new file mode 100644 index 0000000..ff18451 --- /dev/null +++ b/tests/ManualVersionTest.php @@ -0,0 +1,140 @@ + User::class, + 'versionable.user_model' => User::class, + ]); + + $this->user = User::create(['name' => 'marijoo']); + $this->actingAs($this->user); + } + + /** + * @test + */ + public function user_can_create_versions_manually() + { + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + + $this->assertCount(1, $post->refresh()->versions); + $this->assertEquals('version1', $post->latestVersion->contents['title']); + + $post->title = 'version2'; + + $this->assertNotNull($post->createVersion()); + $this->assertCount(2, $post->refresh()->versions); + $this->assertEquals('version2', $post->latestVersion->contents['title']); + + $post->title = 'version3'; + + $this->assertNotNull($post->createVersion()); + $this->assertCount(3, $post->refresh()->versions); + $this->assertEquals('version3', $post->latestVersion->contents['title']); + } + + /** + * @test + */ + public function user_cannot_create_versions_manually_without_changes() + { + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + + $this->assertCount(1, $post->refresh()->versions); + $this->assertEquals('version1', $post->latestVersion->contents['title']); + + $this->assertNull($post->createVersion()); + $this->assertNull($post->createVersion()); + $this->assertNull($post->createVersion()); + + $this->assertCount(1, $post->refresh()->versions); + } + + /** + * @test + */ + public function user_cannot_create_versions_manually_by_passing_attributes() + { + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + $post->setVersionStrategy(VersionStrategy::SNAPSHOT); + + $this->assertCount(1, $post->refresh()->versions); + $this->assertEquals('version1', $post->latestVersion->contents['title']); + + $this->assertNull($post->createVersion()); + + $this->assertNotNull($post->createVersion(['title' => 'version2'])); + $this->assertCount(2, $post->refresh()->versions); + $this->assertEquals('version2', $post->latestVersion->contents['title']); + + $this->assertNotNull($post->createVersion(['title' => 'version3'])); + $this->assertCount(3, $post->refresh()->versions); + $this->assertEquals('version3', $post->latestVersion->contents['title']); + } + + /** + * @test + */ + public function user_can_create_versions_manually_if_versioning_is_disabled() + { + Post::disableVersioning(); + + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + + $this->assertCount(0, $post->refresh()->versions); + + $post->update(['title' => 'version2']); + + $this->assertCount(0, $post->refresh()->versions); + + $this->assertNotNull($post->createVersion(['title' => 'version3'])); + $this->assertCount(1, $post->refresh()->versions); + $this->assertEquals('version3', $post->latestVersion->contents['title']); + } + + /** + * @test + */ + public function attributes_will_be_merged_in_snapshot_mode() + { + Post::disableVersioning(); + + $post = Post::create(['title' => 'version1', 'content' => 'version1 content']); + $post->setVersionStrategy(VersionStrategy::DIFF); + + $this->assertNotNull($post->createVersion(['title' => 'version3'])); + $this->assertCount(1, $post->refresh()->versions); + $this->assertEquals(['title' => 'version3'], $post->latestVersion->contents); + + $post->setVersionStrategy(VersionStrategy::SNAPSHOT); + + $this->assertNotNull($post->createVersion(['title' => 'version4'])); + $this->assertCount(2, $post->refresh()->versions); + + $this->assertTrue(collect($post->latestVersion->contents)->has([ + 'id', + 'title', + 'content', + 'extends', + 'user_id', + 'created_at', + 'updated_at', + ])); + + $this->assertCount(7, $post->latestVersion->contents); + } +}