diff --git a/README.md b/README.md index 0f20eb6..57ec855 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Publish the config file and migration: $ php artisan vendor:publish --provider="TestMonitor\Revisable\RevisableServiceProvider" --tag="config" $ php artisan vendor:publish --provider="TestMonitor\Revisable\RevisableServiceProvider" --tag="migrations" -Once published, you can configure your user model, revision model, and name generator in `config/revisable.php`. +Once published, you can configure your user model and revision model in `config/revisable.php`. Run the migration to create the `revisions` table: @@ -289,35 +289,15 @@ The window is measured from the revision's last update, so it resets on every sa The living snapshot captures the post-save state, consistent with normal revision behaviour. After two saves in draft, the snapshot holds the state of the most recent save, which serves as the rollback point. -#### Custom revision naming +#### Version numbers -The default `VersionNameGenerator` names revisions sequentially (v1, v2, …). You can provide your own generator by implementing the `NameGenerator` contract and registering it in the options: +Every revision automatically gets a sequential `version` number (1, 2, 3, …), scoped to its model instance. Unlike a generated name, the version is a plain integer, so it carries no language or formatting choices — prefix it with whatever translated string fits your application: ```php -use TestMonitor\Revisable\Contracts\NameGenerator; - -class TimestampNameGenerator implements NameGenerator -{ - public function generate(Model $model): string - { - return now()->toDateTimeString(); - } -} +__('Version :number', ['number' => $revision->version]); ``` -```php -public function getRevisionOptions(): RevisableOptions -{ - return RevisableOptions::defaults() - ->nameRevisionUsing(new TimestampNameGenerator); -} -``` - -Pass `null` to disable automatic naming entirely: - -```php -return RevisableOptions::defaults()->nameRevisionUsing(null); -``` +Use `saveAsRevision()` (see below) if you also want a caller-chosen label on top of the version number. --- diff --git a/config/revisionable.php b/config/revisionable.php index af88f0e..94edb56 100644 --- a/config/revisionable.php +++ b/config/revisionable.php @@ -1,7 +1,6 @@ Revision::class, - /* - * The generator class used to produce a name for each revision. - * Set to null to disable automatic naming. - */ - 'name_generator' => VersionNameGenerator::class, - /* * You can specify an auth driver here that gets user models. * If this is null we'll use the current Laravel auth driver. diff --git a/database/migrations/create_revisions_table.php.stub b/database/migrations/create_revisions_table.php.stub index 0c9e285..2d29cdf 100644 --- a/database/migrations/create_revisions_table.php.stub +++ b/database/migrations/create_revisions_table.php.stub @@ -14,6 +14,7 @@ return new class extends Migration $table->morphs('revisionable'); $table->string('name')->nullable(); + $table->unsignedInteger('version'); $table->json('metadata')->nullable(); $table->json('properties')->nullable(); $table->json('changed')->nullable(); @@ -23,6 +24,8 @@ return new class extends Migration $table->foreign('user_id')->references('id')->on('users')->onDelete('set null')->onUpdate('cascade'); $table->timestamps(); + + $table->unique(['revisionable_type', 'revisionable_id', 'version']); }); } diff --git a/src/Concerns/HasRevisions.php b/src/Concerns/HasRevisions.php index ca23a73..b8f5b9f 100644 --- a/src/Concerns/HasRevisions.php +++ b/src/Concerns/HasRevisions.php @@ -200,7 +200,6 @@ public function createNewRevision(): Revision|bool $revision = app(Revisioner::class) ->for($this) - ->nameUsing($options->nameGenerator) ->onlyFields($options->fields) ->exceptFields($options->exceptFields) ->withRelations($options->relations) @@ -232,7 +231,6 @@ public function saveAsRevision(?string $name = null, array $properties = [], ?bo ->for($this) ->name($name) ->properties($properties) - ->nameUsing($options->nameGenerator) ->onlyFields($options->fields) ->exceptFields($options->exceptFields) ->withRelations($options->relations) @@ -451,7 +449,6 @@ protected function saveAsRollbackRevision(RevisableOptions $options, RevisionCon { return app(Revisioner::class) ->for($this) - ->nameUsing($options->nameGenerator) ->onlyFields($options->fields) ->exceptFields($options->exceptFields) ->withRelations($options->relations) diff --git a/src/Contracts/NameGenerator.php b/src/Contracts/NameGenerator.php deleted file mode 100644 index a8af990..0000000 --- a/src/Contracts/NameGenerator.php +++ /dev/null @@ -1,10 +0,0 @@ -revisions()->count() + 1); - } -} diff --git a/src/Models/Revision.php b/src/Models/Revision.php index 4ec367d..203c5d4 100644 --- a/src/Models/Revision.php +++ b/src/Models/Revision.php @@ -22,6 +22,7 @@ class Revision extends Model implements RevisionContract protected $fillable = [ 'name', + 'version', 'metadata', 'properties', 'changed', @@ -32,6 +33,7 @@ class Revision extends Model implements RevisionContract ]; protected $casts = [ + 'version' => 'integer', 'metadata' => 'array', 'properties' => 'array', 'changed' => 'array', diff --git a/src/RevisableOptions.php b/src/RevisableOptions.php index 10d74c4..f570040 100644 --- a/src/RevisableOptions.php +++ b/src/RevisableOptions.php @@ -5,9 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; -use TestMonitor\Revisable\Contracts\NameGenerator; use TestMonitor\Revisable\Contracts\Revision as RevisionContract; -use TestMonitor\Revisable\Generators\NameGeneratorFactory; class RevisableOptions { @@ -76,22 +74,12 @@ class RevisableOptions */ public ?array $exceptRestoringRelations = []; - /** - * The generator used to produce a name for each revision. - * Defaults to the generator configured in config/revisable.php. Set to null to disable auto-naming. - */ - public ?NameGenerator $nameGenerator = null; - /** * Start configuring model with the default options. */ public static function defaults(): self { - $options = new static; - - $options->nameGenerator = NameGeneratorFactory::create(); - - return $options; + return new static; } /** @@ -223,14 +211,4 @@ public function withoutRestoringRelations(string ...$relations): self return $this; } - - /** - * Set a custom name generator for revisions. Pass null to disable auto-naming. - */ - public function nameRevisionUsing(?NameGenerator $generator): self - { - $this->nameGenerator = $generator; - - return $this; - } } diff --git a/src/Revisioner.php b/src/Revisioner.php index 82f49a3..741c14f 100644 --- a/src/Revisioner.php +++ b/src/Revisioner.php @@ -9,7 +9,6 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Traits\Conditionable; -use TestMonitor\Revisable\Contracts\NameGenerator; use TestMonitor\Revisable\Contracts\Revision as RevisionContract; use TestMonitor\Revisable\Enums\RevisionType; use TestMonitor\Revisable\Models\Revision; @@ -34,8 +33,6 @@ class Revisioner protected ?array $exceptRestoringRelations = []; - protected ?NameGenerator $nameGenerator = null; - protected RevisionType $revisionType = RevisionType::Default; public function __construct(protected UserResolver $userResolver) {} @@ -68,13 +65,6 @@ public function name(?string $name): static return $this; } - public function nameUsing(?NameGenerator $generator): static - { - $this->nameGenerator = $generator; - - return $this; - } - public function onlyFields(array $fields): static { $this->fields = $fields; @@ -118,7 +108,7 @@ public function build(): Revision $revision = new Revision; $revision->user_id = $this->userResolver->resolve(); - $revision->name = $this->resolveName(); + $revision->name = $this->name; $revision->metadata = $this->buildData(); $revision->changed = $this->buildChanges($revision); $revision->properties = $this->properties ?: null; @@ -134,6 +124,7 @@ public function save(): Revision { return DB::transaction(function () { $revision = $this->build(); + $revision->version = $this->resolveVersion(); $this->model->revisions()->save($revision); @@ -213,13 +204,13 @@ public function prune(): void } } - protected function resolveName(): ?string + /** + * Resolve the sequential version number for the new revision. + * Uses the max version rather than the row count, so pruning never reuses a number. + */ + protected function resolveVersion(): int { - if ($this->name !== null) { - return $this->name; - } - - return $this->nameGenerator?->generate($this->model); + return ($this->model->revisions()->max('version') ?? 0) + 1; } /** diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php index 48490a7..551608f 100644 --- a/tests/ConfigurationTest.php +++ b/tests/ConfigurationTest.php @@ -4,8 +4,6 @@ use PHPUnit\Framework\Attributes\Test; use TestMonitor\Revisable\Exceptions\InvalidConfiguration; -use TestMonitor\Revisable\Generators\NameGeneratorFactory; -use TestMonitor\Revisable\Generators\VersionNameGenerator; use TestMonitor\Revisable\Models\Revision; use TestMonitor\Revisable\RevisableServiceProvider; use TestMonitor\Revisable\Tests\Models\Author; @@ -48,58 +46,6 @@ public function it_throws_when_the_configured_revision_model_class_does_not_exis RevisableServiceProvider::determineRevisionModel(); } - // Name generator - - #[Test] - public function it_returns_null_when_no_name_generator_is_configured() - { - // Given - config()->set('revisable.name_generator', null); - - // When - $generator = NameGeneratorFactory::create(); - - // Then - $this->assertNull($generator); - } - - #[Test] - public function it_creates_a_generator_instance_when_the_class_is_valid() - { - // Given - config()->set('revisable.name_generator', VersionNameGenerator::class); - - // When - $generator = NameGeneratorFactory::create(); - - // Then - $this->assertInstanceOf(VersionNameGenerator::class, $generator); - } - - #[Test] - public function it_throws_when_the_configured_name_generator_does_not_implement_the_contract() - { - // Given - config()->set('revisable.name_generator', \stdClass::class); - - // When / Then - $this->expectException(InvalidConfiguration::class); - - NameGeneratorFactory::create(); - } - - #[Test] - public function it_throws_when_the_configured_name_generator_class_does_not_exist() - { - // Given - config()->set('revisable.name_generator', 'App\\Generators\\NonExistentGenerator'); - - // When / Then - $this->expectException(InvalidConfiguration::class); - - NameGeneratorFactory::create(); - } - // User model #[Test] diff --git a/tests/CreatingRevisionsTest.php b/tests/CreatingRevisionsTest.php index a922d37..c0dd38a 100644 --- a/tests/CreatingRevisionsTest.php +++ b/tests/CreatingRevisionsTest.php @@ -291,7 +291,7 @@ public function getRevisionOptions(): RevisableOptions $this->modifyPost($post); // Revision 1: 'Another post name', votes=20 // When - $post->rollbackToRevision($post->revisions()->oldest()->firstOrFail()); + $post->rollbackToRevision($post->revisions()->oldest('version')->firstOrFail()); // Then $this->assertEquals('Post name', $post->name); @@ -478,7 +478,7 @@ public function it_can_scope_revisions_to_only_rollbacks() $this->modifyPost($post); $this->modifyPost($post, ['name' => 'Another name']); - $post->rollbackToRevision($post->revisions()->oldest()->firstOrFail()); + $post->rollbackToRevision($post->revisions()->oldest('version')->firstOrFail()); // When $revisions = $post->revisions()->onlyRollbacks()->get(); @@ -496,7 +496,7 @@ public function it_can_scope_revisions_to_not_rollbacks() $this->modifyPost($post); $this->modifyPost($post, ['name' => 'Another name']); - $post->rollbackToRevision($post->revisions()->oldest()->firstOrFail()); + $post->rollbackToRevision($post->revisions()->oldest('version')->firstOrFail()); // When $revisions = $post->revisions()->notRollback()->get(); @@ -521,9 +521,9 @@ public function getRevisionOptions(): RevisableOptions $post = $this->createPost($post); DB::table('revisions')->insert([ - ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], - ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], - ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], + ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'version' => 1, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], + ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'version' => 2, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], + ['revisionable_type' => get_class($post), 'revisionable_id' => $post->id, 'version' => 3, 'metadata' => json_encode([]), 'created_at' => now(), 'updated_at' => now()], ]); $this->assertEquals(3, $post->revisions()->count()); diff --git a/tests/Database/Migrations/0000_00_00_000000_create_testing_tables.php b/tests/Database/Migrations/0000_00_00_000000_create_testing_tables.php index 61c3200..ceb063f 100644 --- a/tests/Database/Migrations/0000_00_00_000000_create_testing_tables.php +++ b/tests/Database/Migrations/0000_00_00_000000_create_testing_tables.php @@ -89,6 +89,7 @@ public function up(): void $table->integer('user_id')->unsigned()->index()->nullable(); $table->morphs('revisionable'); $table->string('name')->nullable(); + $table->unsignedInteger('version'); $table->json('metadata')->nullable(); $table->json('properties')->nullable(); $table->json('changed')->nullable(); @@ -97,6 +98,7 @@ public function up(): void $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->onDelete('set null')->onUpdate('cascade'); + $table->unique(['revisionable_type', 'revisionable_id', 'version']); }); } diff --git a/tests/RevisionNamingTest.php b/tests/RevisionNamingTest.php index 7f1962d..2db1d58 100644 --- a/tests/RevisionNamingTest.php +++ b/tests/RevisionNamingTest.php @@ -2,10 +2,7 @@ namespace TestMonitor\Revisable\Tests; -use Illuminate\Database\Eloquent\Model; use PHPUnit\Framework\Attributes\Test; -use TestMonitor\Revisable\Contracts\NameGenerator; -use TestMonitor\Revisable\Generators\VersionNameGenerator; use TestMonitor\Revisable\Models\Revision; use TestMonitor\Revisable\RevisableOptions; use TestMonitor\Revisable\Tests\Models\Post; @@ -13,72 +10,10 @@ class RevisionNamingTest extends TestCase { #[Test] - public function it_generates_sequential_version_names_using_the_version_name_generator() + public function it_stores_no_name_by_default() { // Given - $post = new class extends Post - { - public function getRevisionOptions(): RevisableOptions - { - return parent::getRevisionOptions()->nameRevisionUsing(new VersionNameGenerator); - } - }; - - $post = $this->createPost($post); - - // When - $this->modifyPost($post); - $this->modifyPost($post, ['name' => 'Yet another post name', 'slug' => 'yet-another-post-slug', 'content' => 'Yet another post content', 'votes' => 30, 'views' => 300]); - $this->modifyPost($post); - - // Then - $names = $post->revisions()->oldest()->pluck('name'); - - $this->assertEquals(['v1', 'v2', 'v3'], $names->all()); - } - - #[Test] - public function it_uses_a_custom_name_generator_when_configured() - { - // Given - $post = new class extends Post - { - public function getRevisionOptions(): RevisableOptions - { - $generator = new class implements NameGenerator - { - public function generate(Model $model): string - { - return 'snapshot'; - } - }; - - return parent::getRevisionOptions()->nameRevisionUsing($generator); - } - }; - - $post = $this->createPost($post); - - // When - $this->modifyPost($post); - - // Then - $this->assertEquals('snapshot', $post->revisions()->firstOrFail()->name); - } - - #[Test] - public function it_stores_no_name_when_auto_naming_is_disabled() - { - // Given - $post = new class extends Post - { - public function getRevisionOptions(): RevisableOptions - { - return parent::getRevisionOptions()->nameRevisionUsing(null); - } - }; - - $post = $this->createPost($post); + $post = $this->createPost(); // When $this->modifyPost($post); @@ -100,27 +35,6 @@ public function it_uses_a_manually_provided_name_when_saving_as_revision() $this->assertEquals('my-checkpoint', Revision::firstOrFail()->name); } - #[Test] - public function it_prioritises_the_manual_name_over_the_configured_generator() - { - // Given - $post = new class extends Post - { - public function getRevisionOptions(): RevisableOptions - { - return parent::getRevisionOptions()->nameRevisionUsing(new VersionNameGenerator); - } - }; - - $post = $this->createPost($post); - - // When - $post->saveAsRevision('explicit-name'); - - // Then - $this->assertEquals('explicit-name', Revision::firstOrFail()->name); - } - #[Test] public function it_preserves_the_revision_name_when_replacing() { @@ -129,20 +43,18 @@ public function it_preserves_the_revision_name_when_replacing() { public function getRevisionOptions(): RevisableOptions { - return parent::getRevisionOptions() - ->nameRevisionUsing(new VersionNameGenerator) - ->replaceWhen(true); + return parent::getRevisionOptions()->replaceWhen(true); } }; $post = $this->createPost($post); - $this->modifyPost($post); + $post->saveAsRevision('first-name'); // When $this->modifyPost($post, ['name' => 'Third name']); // Then $this->assertCount(1, $post->revisions()->get()); - $this->assertEquals('v1', $post->revisions()->firstOrFail()->name); + $this->assertEquals('first-name', $post->revisions()->firstOrFail()->name); } } diff --git a/tests/RevisionVersioningTest.php b/tests/RevisionVersioningTest.php new file mode 100644 index 0000000..6a79244 --- /dev/null +++ b/tests/RevisionVersioningTest.php @@ -0,0 +1,114 @@ +createPost(); + + // When + $this->modifyPost($post); + $this->modifyPost($post, ['name' => 'Yet another post name', 'slug' => 'yet-another-post-slug', 'content' => 'Yet another post content', 'votes' => 30, 'views' => 300]); + $this->modifyPost($post); + + // Then + $versions = $post->revisions()->oldest('version')->pluck('version'); + + $this->assertEquals([1, 2, 3], $versions->all()); + } + + #[Test] + public function it_preserves_the_revision_version_when_replacing() + { + // Given + $post = new class extends Post + { + public function getRevisionOptions(): RevisableOptions + { + return parent::getRevisionOptions()->replaceWhen(true); + } + }; + + $post = $this->createPost($post); + $this->modifyPost($post); + + // When + $this->modifyPost($post, ['name' => 'Third name']); + + // Then + $this->assertCount(1, $post->revisions()->get()); + $this->assertEquals(1, $post->revisions()->firstOrFail()->version); + } + + #[Test] + public function it_continues_the_version_sequence_after_a_rollback() + { + // Given + $post = $this->createPost(); + $this->modifyPost($post); + + $firstRevision = $post->revisions()->oldest('version')->firstOrFail(); + + // When + $post->rollbackToRevision($firstRevision); + + // Then + $versions = $post->revisions()->oldest('version')->pluck('version'); + + $this->assertEquals([1, 2], $versions->all()); + } + + #[Test] + public function it_does_not_reuse_version_numbers_after_pruning() + { + // Given + $post = new class extends Post + { + public function getRevisionOptions(): RevisableOptions + { + return parent::getRevisionOptions()->limitRevisionsTo(3); + } + }; + + $post = $this->createPost($post); + + // When + for ($i = 0; $i < 6; $i++) { + $this->modifyPost($post, ['votes' => 20 + $i]); + } + + // Then + $versions = $post->revisions()->oldest('version')->pluck('version')->all(); + + $this->assertEquals([4, 5, 6], $versions); + } + + #[Test] + public function it_rejects_a_duplicate_version_for_the_same_model() + { + // Given + $post = $this->createPost(); + $this->modifyPost($post); + + // Then + $this->expectException(QueryException::class); + + // When + (new Revision)->forceFill([ + 'revisionable_id' => $post->id, + 'revisionable_type' => get_class($post), + 'version' => $post->revisions()->firstOrFail()->version, + 'metadata' => [], + ])->save(); + } +}