From 3107200769e9ffd6096c8aee017a85cbc58334cb Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Sat, 5 Oct 2024 20:14:30 +0200 Subject: [PATCH] initial commit --- .github/CONTRIBUTING.md | 55 ++++ .github/FUNDING.yml | 1 + .github/SECURITY.md | 3 + .github/stale.yml | 17 + .gitignore | 5 + LICENSE.md | 21 ++ README.md | 433 +++++++++++++++++++++++++ composer.json | 48 +++ config/versions.php | 37 +++ src/Concerns/HasVersions.php | 272 ++++++++++++++++ src/Contracts/Draftable.php | 16 + src/Contracts/Versionable.php | 55 ++++ src/Facades/LaravelVersions.php | 13 + src/Handlers/DraftService.php | 81 +++++ src/LaravelVersions.php | 14 + src/Macros/VersionsBlueprintMacros.php | 59 ++++ src/Observers/VersionObserver.php | 42 +++ src/VersionsServiceProvider.php | 33 ++ tests/DefaultTest.php | 37 +++ tests/Unit/DraftTest.php | 67 ++++ tests/Unit/ExampleTest.php | 71 ++++ tests/Unit/PublishTest.php | 77 +++++ 22 files changed, 1457 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/SECURITY.md create mode 100644 .github/stale.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/versions.php create mode 100644 src/Concerns/HasVersions.php create mode 100644 src/Contracts/Draftable.php create mode 100644 src/Contracts/Versionable.php create mode 100644 src/Facades/LaravelVersions.php create mode 100644 src/Handlers/DraftService.php create mode 100644 src/LaravelVersions.php create mode 100644 src/Macros/VersionsBlueprintMacros.php create mode 100644 src/Observers/VersionObserver.php create mode 100644 src/VersionsServiceProvider.php create mode 100644 tests/DefaultTest.php create mode 100644 tests/Unit/DraftTest.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/PublishTest.php diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..b4ae1c4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4a564d5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: vildanbina diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..8c669d8 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email vildanbina@gmail.com instead of using the issue tracker. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..9d23fb5 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 30 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd39987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +/.idea +/.vscode +composer.lock +.DS_Store diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..efaed2d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Vildan Bina + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e9bea5 --- /dev/null +++ b/README.md @@ -0,0 +1,433 @@ +[![Latest Stable Version](http://poser.pugx.org/vildanbina/laravel-versions/v)](https://packagist.org/packages/vildanbina/laravel-versions) +[![Total Downloads](http://poser.pugx.org/vildanbina/laravel-versions/downloads)](https://packagist.org/packages/vildanbina/laravel-versions) +[![Latest Unstable Version](http://poser.pugx.org/vildanbina/laravel-versions/v/unstable)](https://packagist.org/packages/vildanbina/laravel-versions) +[![License](http://poser.pugx.org/vildanbina/laravel-versions/license)](https://packagist.org/packages/vildanbina/laravel-versions) +[![PHP Version Require](http://poser.pugx.org/vildanbina/laravel-versions/require/php)](https://packagist.org/packages/vildanbina/laravel-versions) + +# Laravel Versions + +**Laravel Versions** is a package that adds powerful draft and versioning capabilities to your Eloquent models. With +this package, you can create drafts, manage versions, and publish changes to your models without affecting the currently +published version. When a model is updated, it modifies the existing active draft instead of creating a new one for each +change. If no active draft exists, a new one is created. Once you're ready, you can publish the draft to make it the +active version while maintaining a history of all previous versions. + +## Requirements + +- PHP >= 8.0 +- Laravel 9.x, 10.x, or 11.x + +## Installation + +You can install the package via Composer: + +~~~bash +composer require vildanbina/laravel-versions +~~~ + +After installation, you need to publish the configuration file: + +~~~bash +php artisan vendor:publish --provider="VildanBina\LaravelVersions\DraftsServiceProvider" +~~~ + +### Database Migrations + +The package provides schema macros to add the necessary columns to your tables. You'll need to update your existing +migrations or create new ones to add the drafts columns to your models' tables. + +To add the drafts columns to a table (e.g., `posts`), you can use the `drafts()` macro in your migration: + +~~~php +versionables(); + }); + } + + public function down() + { + Schema::table('posts', function (Blueprint $table) { + $table->dropVersionables(); + }); + } +} +~~~ + +After updating your migrations, run: + +~~~bash +php artisan migrate +~~~ + +## Configuration + +The package includes a configuration file `config/drafts.php` that allows you to customize column names and the +authentication guard. Below is the default configuration: + +~~~php + [ + 'is_current' => 'is_current', + 'is_published' => 'is_published', + 'published_at' => 'published_at', + 'uuid' => 'uuid', + 'publisher_morph_name' => 'publisher', + ], + + 'auth' => [ + 'guard' => 'web', + ], +]; +~~~ + +You can customize these settings as needed. + +## Getting Started + +Follow these steps to set up versioning for your models: + +1. **Install the package via Composer**: + + ~~~bash + composer require vildanbina/laravel-versions + ~~~ + +2. **Publish the configuration file**: + + ~~~bash + php artisan vendor:publish --provider="VildanBina\LaravelVersions\VersionsServiceProvider" + ~~~ + +3. **Add the drafts columns to your database**: + + Create a new migration or update an existing one to include the `drafts()` macro: + + ~~~php + versionables(); + }); + } + + public function down() + { + Schema::table('posts', function (Blueprint $table) { + $table->dropVersionables(); + }); + } + } + ~~~ + + Then run: + + ~~~bash + php artisan migrate + ~~~ + +4. **Update your model**: + + Implement the `Versionable` interface and use the `HasVersions` trait: + + ~~~php + 'My First Post', 'content' => 'Hello World']); + + // Publish the postha + + $post->publish(); + + // Update the post, which modifies the existing draft or creates a new one + $post->update(['content' => 'Updated content']); + + // Get the current draft + $draft = $post->draft; + + // Publish the draft + $draft->publish(); + ~~~ + +## Usage + +To enable versioning for a model, implement the `Versionable` interface and use the `HasVersions` trait provided by the +package. + +### Example with a `Post` Model + +First, update your `Post` model: + +~~~php + 'Initial Title', 'content' => 'Initial Content']); + +// Publish the post +$post->publish(); +~~~ + +#### Updating a Model and Working with Drafts + +~~~php +update(['title' => 'Updated Title']); + +// Get the current draft +$draft = $post->draft; + +// Check if the post is a draft +if ($draft->isDraft()) { + echo "This post is currently a draft!"; +} + +// Publish the updated draft +$draft->publish(); +~~~ + +#### Retrieving Version History + +~~~php +revisions; + +// Loop through revisions +foreach ($revisions as $revision) { + echo $revision->title . ' - ' . ($revision->is_published ? 'Published' : 'Draft'); +} +~~~ + +#### Getting the Publisher + +~~~php +publisher; +~~~ + +## Query Scopes + +The package provides the following query scopes that can be used for querying models: + +- **`whereCurrent()`**: Retrieve records where `is_current` is `true`. +- **`wherePublished()`**: Retrieve records where `is_published` is `true`. +- **`withoutCurrent()`**: Retrieve records where `is_current` is `false`. +- **`excludeRevision($revision)`**: Exclude a specific revision from the query. + +### Examples + +~~~php +get(); + +// Retrieve all published posts +$publishedPosts = Post::wherePublished()->get(); + +// Retrieve all drafts +$drafts = Post::whereCurrent()->wherePublished(false)->get(); +~~~ + +## Tips & Best Practices + +- **Using Transactions**: When performing operations that involve multiple steps, it's recommended to use database + transactions to ensure data consistency. If any step fails, all changes will be rolled back, preventing any incomplete + versioning states. + + ~~~php + update(['title' => 'Updated Title']); + $post->publish(); + }); + ~~~ + +- **Customizing Excluded Columns**: Use the `$excludedColumns` property in your model to specify columns that should not + be versioned, such as timestamps or other metadata. + +## Extensibility and Customization + +The package is designed to be flexible and can be customized to fit your application's needs. + +### Overriding Methods + +You can extend the functionality by overriding methods in the `HasVersions` trait within your model. + +### Custom Events + +Leverage the `publishing` and `published` model events to add custom logic when a draft is being published. + +### Customizing Authentication Guard + +You can change the authentication guard used to associate the publisher by updating the `auth.guard` setting in the +`config/drafts.php` configuration file. + +## Observers + +The package automatically handles the `creating`, `saving`, and `published` events via the `VersionObserver` to manage +the +draft lifecycle. If you need to customize this behavior, you can create your own observer or extend the existing one. + +## To Do + +- **Handling Relationships**: Implementing support for all relationships in the versioning process, allowing + relationships to be included and managed across drafts and published versions. + +- **Service for Detecting Changes**: Create a service that can identify and compare all changes between different + versions of a model, providing a clear history of what has changed across versions. + +- **Enable/Disable Versioning**: Add functionality to globally enable or disable the versioning system, for scenarios + such as when super admins want to bypass the versioning process or temporarily deactivate it. + +## Contributing + +Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please e-mail vildanbina@gmail.com to report any security vulnerabilities instead of the issue tracker. + +## Credits + +- [Vildan Bina](https://github.com/vildanbina) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7eac587 --- /dev/null +++ b/composer.json @@ -0,0 +1,48 @@ +{ + "name": "vildanbina/laravel-versions", + "description": "A Laravel package for managing model drafts.", + "keywords": [ + "laravel", + "versioning", + "drafts", + "models" + ], + "license": "MIT", + "version": "1.0.0", + "authors": [ + { + "name": "Vildan Bina", + "email": "vildanbina@gmail.com", + "homepage": "https://www.vildanbina.com", + "role": "Developer" + } + ], + "require": { + "php": ">=8.0", + "laravel/framework": "^9.0|^10.0|^11.0", + "illuminate/support": "^9.0|^10.0|^11.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "autoload": { + "psr-4": { + "VildanBina\\LaravelVersions\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "VildanBina\\LaravelVersions\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "VildanBina\\LaravelVersions\\VersionsServiceProvider" + ], + "aliases": { + "LaravelVersions": "VildanBina\\LaravelVersions\\Facades\\LaravelVersions" + } + } + } +} diff --git a/config/versions.php b/config/versions.php new file mode 100644 index 0000000..75a0fce --- /dev/null +++ b/config/versions.php @@ -0,0 +1,37 @@ + [ + /* + * Boolean column that marks a row as the current version of the data for editing. + */ + 'is_current' => 'is_current', + + /* + * Boolean column that marks a row as live and displayable to the public. + */ + 'is_published' => 'is_published', + + /* + * Timestamp column that stores the date and time when the row was published. + */ + 'published_at' => 'published_at', + + /* + * UUID column that stores the unique identifier of the model drafts. + */ + 'uuid' => 'uuid', + + /* + * Name of the morph relationship to the publishing user. + */ + 'publisher_morph_name' => 'publisher', + ], + + 'auth' => [ + /* + * The guard to fetch the logged-in user from for the publisher relation. + */ + 'guard' => 'web', + ], +]; diff --git a/src/Concerns/HasVersions.php b/src/Concerns/HasVersions.php new file mode 100644 index 0000000..f49f971 --- /dev/null +++ b/src/Concerns/HasVersions.php @@ -0,0 +1,272 @@ +addObservableEvents(['publishing', 'published']); + + $this->mergeFillable([ + $this->getPublishedAtColumn(), + $this->getIsCurrentColumn(), + $this->getIsPublishedColumn(), + $this->getUuidColumn(), + ]); + + $this->mergeCasts([ + $this->getPublishedAtColumn() => 'datetime', + $this->getIsCurrentColumn() => 'boolean', + $this->getIsPublishedColumn() => 'boolean', + ]); + + $this->registerObserver(VersionObserver::class); + } + + /** + * Create a new draft version of the model. + * + * @return $this|null + */ + public function newDraft(): ?static + { + return $this->draftService()->createDraft(); + } + + /** + * Get the draft service instance. + */ + public function draftService(): Draftable + { + return new DraftService($this); + } + + /** + * Set the publisher of the model. + * + * @return $this + */ + public function setPublisher(): static + { + $publisherColumns = $this->getPublisherColumns(); + + if ($this->{$publisherColumns['id']} === null && LaravelVersions::getCurrentUser()) { + $this->publisher()->associate(LaravelVersions::getCurrentUser()); + } + + return $this; + } + + /** + * Get the names of the publisher relation columns. + * + * @return array + */ + #[ArrayShape(['id' => 'string', 'type' => 'string'])] + public function getPublisherColumns(): array + { + $morphName = config('versions.column_names.publisher_morph_name', 'publisher'); + + return [ + 'id' => $morphName.'_id', + 'type' => $morphName.'_type', + ]; + } + + /** + * Define the publisher morph relation. + * + * @return MorphTo + */ + public function publisher() + { + return $this->morphTo(config('versions.column_names.publisher_morph_name', 'publisher')); + } + + /** + * Get the name of the "published at" column. + */ + public function getPublishedAtColumn(): string + { + return defined(static::class.'::PUBLISHED_AT') + ? static::PUBLISHED_AT + : config('versions.column_names.published_at', 'published_at'); + } + + /** + * Get the name of the "is current" column. + */ + public function getIsCurrentColumn(): string + { + return defined(static::class.'::IS_CURRENT') + ? static::IS_CURRENT + : config('versions.column_names.is_current', 'is_current'); + } + + /** + * Get the name of the "is published" column. + */ + public function getIsPublishedColumn(): string + { + return defined(static::class.'::IS_PUBLISHED') + ? static::IS_PUBLISHED + : config('versions.column_names.is_published', 'is_published'); + } + + /** + * Get the name of the "UUID" column. + */ + public function getUuidColumn(): string + { + return defined(static::class.'::UUID') + ? static::UUID + : config('versions.column_names.uuid', 'uuid'); + } + + /** + * Publish the model. + * + * @return $this + */ + public function publish(): static + { + if ($this->fireModelEvent('publishing') === false) { + return $this; + } + + $published = $this->draftService()->publish(); + + $published->fireModelEvent('published'); + + return $published; + } + + /** + * Get the columns to exclude during operations. + */ + public function getExcludedColumns(): array + { + return array_merge( + array_values($this->getPublisherColumns()), + [$this->getPublishedAtColumn()], + property_exists($this, 'excludedColumns') ? $this->excludedColumns : [] + ); + } + + /** + * Get the fully qualified publisher relation columns. + */ + public function getQualifiedPublisherColumns(): array + { + $columns = $this->getPublisherColumns(); + + return [ + 'id' => $this->qualifyColumn($columns['id']), + 'type' => $this->qualifyColumn($columns['type']), + ]; + } + + /** + * Scope a query to only include current versions. + */ + public function scopeWhereCurrent(Builder $query): void + { + $query->where($this->getIsCurrentColumn(), true); + } + + /** + * Scope a query to only include published versions. + */ + public function scopeWherePublished(Builder $query, bool $value = true): void + { + $query->where($this->getIsPublishedColumn(), $value); + } + + /** + * Scope a query to exclude current versions. + */ + public function scopeWithoutCurrent(Builder $query): void + { + $query->where($this->getIsCurrentColumn(), false); + } + + /** + * Scope a query to exclude a specific revision. + * + * @return void + */ + public function scopeExcludeRevision(Builder $query, int|Model $exclude): Builder + { + $excludeId = $exclude instanceof Model ? $exclude->getKey() : $exclude; + + return $query->where($this->getKeyName(), '!=', $excludeId); + } + + /** + * Get all revisions of the model. + * + * @return HasMany + */ + public function revisions() + { + return $this->hasMany(static::class, $this->getUuidColumn(), $this->getUuidColumn()); + } + + /** + * Get the published version of the model. + * + * @return HasOne + */ + public function published() + { + return $this->hasOne(static::class, $this->getUuidColumn(), $this->getUuidColumn()) + ->where($this->getIsPublishedColumn(), true); + } + + /** + * Get the draft version of the model. + * + * @return HasOne + */ + public function draft() + { + return $this->hasOne(static::class, $this->getUuidColumn(), $this->getUuidColumn()) + ->where($this->getIsCurrentColumn(), true) + ->where($this->getIsPublishedColumn(), false); + } + + /** + * Get the draft version excluding the current instance. + * + * @return $this|null + */ + public function draftWithoutSelf(): ?static + { + return $this->draft()->whereNot($this->getKeyName(), $this->getKey())->first(); + } + + /** + * Determine if the model is a draft. + */ + public function isDraft(): bool + { + $draft = $this->draft; + + return $draft !== null && $draft->getKey() !== $this->getKey(); + } +} diff --git a/src/Contracts/Draftable.php b/src/Contracts/Draftable.php new file mode 100644 index 0000000..c8e7dc9 --- /dev/null +++ b/src/Contracts/Draftable.php @@ -0,0 +1,16 @@ + + */ + public function getPublisherColumns(): array; + + /** + * Get the name of the UUID column. + */ + public function getUuidColumn(): string; + + /** + * Get the name of the "is current" column. + */ + public function getIsCurrentColumn(): string; + + /** + * Get the name of the "is published" column. + */ + public function getIsPublishedColumn(): string; + + /** + * Set the publisher of the model. + */ + public function setPublisher(): static; + + /** + * Get the columns to exclude during operations. + * + * @return array + */ + public function getExcludedColumns(): array; + + /** + * Get the name of the "published at" column. + */ + public function getPublishedAtColumn(): string; + + /** + * Get the draft version excluding the current instance. + */ + public function draftWithoutSelf(): ?static; + + /** + * Determine if the model is a draft. + */ + public function isDraft(): bool; +} diff --git a/src/Facades/LaravelVersions.php b/src/Facades/LaravelVersions.php new file mode 100644 index 0000000..f0aa9bb --- /dev/null +++ b/src/Facades/LaravelVersions.php @@ -0,0 +1,13 @@ +model->isDirty()) { + return null; + } + + $excludedColumns = $this->model->getExcludedColumns(); + $uuidColumn = $this->model->getUuidColumn(); + + $draft = $this->model->newInstance(array_merge( + Arr::except($this->model->toArray(), $excludedColumns), + [ + $uuidColumn => $this->model->{$uuidColumn}, + $this->model->getIsCurrentColumn() => true, + $this->model->getIsPublishedColumn() => false, + ] + )); + + $draft->saveQuietly(); + + return $draft; + } + + /** + * Publish the draft model. + */ + public function publish(): Versionable + { + $published = $this->model->published ?? $this->model; // In case of creating + /* @var Versionable $draft */ + $draft = $published->draftWithoutSelf(); + + $draftData = $draft?->toArray() ?? []; + $publishedData = $published->toArray(); + + if ($draft) { + $draft->fill(array_merge( + $publishedData, + [ + $this->model->getIsCurrentColumn() => false, + $this->model->getIsPublishedColumn() => false, + ], + )); + $draft->setPublisher(); + $draft->saveQuietly(); + } + + $published->fill(array_merge( + $draftData, + [ + $this->model->getIsCurrentColumn() => true, + $this->model->getIsPublishedColumn() => true, + $this->model->getPublishedAtColumn() => now(), + ], + )); + + $published->setPublisher(); + $published->saveQuietly(); + + $this->model->setRelation('published', $published->withoutRelations()); + + return $published; + } +} diff --git a/src/LaravelVersions.php b/src/LaravelVersions.php new file mode 100644 index 0000000..087dc4d --- /dev/null +++ b/src/LaravelVersions.php @@ -0,0 +1,14 @@ +user(); + } +} diff --git a/src/Macros/VersionsBlueprintMacros.php b/src/Macros/VersionsBlueprintMacros.php new file mode 100644 index 0000000..b288bbf --- /dev/null +++ b/src/Macros/VersionsBlueprintMacros.php @@ -0,0 +1,59 @@ +uuid($uuid)->nullable(); + $this->timestamp($publishedAt)->nullable(); + $this->boolean($isPublished)->default(false); + $this->boolean($isCurrent)->default(false); + $this->nullableMorphs($publisherMorphName); + + $this->index([$uuid, $isPublished, $isCurrent]); + }); + + Blueprint::macro('dropVersionables', function ( + ?string $uuid = null, + ?string $publishedAt = null, + ?string $isPublished = null, + ?string $isCurrent = null, + ?string $publisherMorphName = null, + ): void { + /** @var Blueprint $this */ + $uuid ??= config('versions.column_names.uuid', 'uuid'); + $publishedAt ??= config('versions.column_names.published_at', 'published_at'); + $isPublished ??= config('versions.column_names.is_published', 'is_published'); + $isCurrent ??= config('versions.column_names.is_current', 'is_current'); + $publisherMorphName ??= config('versions.column_names.publisher_morph_name', 'publisher_morph_name'); + + $this->dropIndex([$uuid, $isPublished, $isCurrent]); + $this->dropMorphs($publisherMorphName); + + $this->dropColumn([ + $uuid, + $publishedAt, + $isPublished, + $isCurrent, + ]); + }); + } +} diff --git a/src/Observers/VersionObserver.php b/src/Observers/VersionObserver.php new file mode 100644 index 0000000..9d98752 --- /dev/null +++ b/src/Observers/VersionObserver.php @@ -0,0 +1,42 @@ +getUuidColumn(); + $model->{$model->getIsCurrentColumn()} = true; + $model->{$model->getIsPublishedColumn()} = false; + + if (! $model->{$uuidColumn}) { + $model->{$uuidColumn} = (string) Str::uuid(); + } + } + + /** + * Handle the "updating" event. + * + * @return bool + */ + public function updating(Versionable $model) + { + if ($model->isDirty() && $model->{$model->getIsPublishedColumn()}) { + $model->fresh()->updateQuietly([$model->getIsCurrentColumn() => false]); + + $draft = $model->newDraft(); + $model->setRelation('draft', $draft); + + return false; + } + + return true; + } +} diff --git a/src/VersionsServiceProvider.php b/src/VersionsServiceProvider.php new file mode 100644 index 0000000..838b29c --- /dev/null +++ b/src/VersionsServiceProvider.php @@ -0,0 +1,33 @@ +mergeConfigFrom(__DIR__.'/../config/versions.php', 'drafts'); + } + + public function register(): void + { + $this->app->singleton(LaravelVersions::class, fn () => new LaravelVersions); + VersionsBlueprintMacros::register(); + } + + /** + * @return string[] + */ + public function provides(): array + { + return [ + 'laravel-versions', + ]; + } +} diff --git a/tests/DefaultTest.php b/tests/DefaultTest.php new file mode 100644 index 0000000..3c6a64d --- /dev/null +++ b/tests/DefaultTest.php @@ -0,0 +1,37 @@ +delete(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user, 'web'); + } + + protected function createPost(array $additionalData = []): Post + { + return Post::factory()->create([ + 'user_id' => $this->user->id, + 'description' => 'Test', + ...$additionalData, + ]); + } +} diff --git a/tests/Unit/DraftTest.php b/tests/Unit/DraftTest.php new file mode 100644 index 0000000..87db870 --- /dev/null +++ b/tests/Unit/DraftTest.php @@ -0,0 +1,67 @@ +createPost(); + $post->publish(); + + $post->title = 'Unknown Title'; + $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); + + $this->assertNotNull($draft); + $this->assertTrue($draft->is_current); + $this->assertFalse($draft->is_published); + $this->assertEquals($post->uuid, $draft->uuid); + } + + public function test_draft_inherits_attributes_from_original() + { + $post = $this->createPost(); + $post->publish(); + + $post->title = 'Original Title'; + $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); + + $this->assertEquals('Original Title', $draft->title); + } + + public function test_draft_has_separate_id_from_original() + { + $post = $this->createPost(); + $post->publish(); + + $post->title = 'Unknown Title'; + $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); + + $this->assertNotEquals($post->id, $draft->id); + $this->assertEquals($post->uuid, $draft->uuid); + } + + public function test_drafting_unpublished_post_returns_null_or_handles_appropriately() + { + $post = $this->createPost(); // Not published + + $draft = $post->newDraft(); + + // Assuming it returns null when trying to create a draft of an unpublished post + $this->assertNull($draft); + } + + public function test_drafting_published_post_creates_new_draft() + { + $post = $this->createPost(); + $post->publish(); + + $post->title = 'Unknown Title'; + $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); + + $this->assertNotNull($draft); + $this->assertFalse($draft->is_published); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..bb9384c --- /dev/null +++ b/tests/Unit/ExampleTest.php @@ -0,0 +1,71 @@ +createPost(); + + $this->assertTrue( + $post->is_current && + ! $post->is_published + ); + } + + public function test_publish_post(): void + { + $post = $this->createPost(); + $post->publish(); + + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'is_current' => true, + 'is_published' => true, + 'uuid' => $post->uuid, + ]); + + // check if there's no other drafts + // since the first post created is published + $this->assertDatabaseMissing('posts', [ + ['id', '!=', $post->id], + 'uuid' => $post->uuid, + ]); + } + + public function test_make_changes_to_published_post(): void + { + $post = $this->createPost(); + $publishedPost = $post->publish(); + + $post = $post->fresh(); + $post->title = $this->faker->text; + $post->description = 'Approved'; + $post->save(); + $draftPost = $post->draftWithoutSelf(); + + $this->assertDatabaseHas('posts', [ + 'id' => $publishedPost->id, + 'is_current' => false, + 'is_published' => true, + 'title' => $publishedPost->title, + 'description' => $publishedPost->description, + 'uuid' => $publishedPost->uuid, + ]); + + $this->assertDatabaseHas('posts', [ + 'id' => $draftPost->id, + 'is_current' => true, + 'is_published' => false, + 'title' => $draftPost->title, + 'uuid' => $publishedPost->uuid, + ]); + } +} diff --git a/tests/Unit/PublishTest.php b/tests/Unit/PublishTest.php new file mode 100644 index 0000000..5287f3b --- /dev/null +++ b/tests/Unit/PublishTest.php @@ -0,0 +1,77 @@ +createPost(); + $post->publish(); + + $post->title = 'Draft Title'; + $post->save(); + + $post->publish(); + + $this->assertEquals('Draft Title', $post->title); + $this->assertTrue($post->is_published); + } + + public function test_publish_sets_published_at_and_publisher() + { + $post = $this->createPost(); + $post->publish(); + + $this->assertNotNull($post->published_at); + $this->assertNotNull($post->publisher); + } + + public function test_publishing_without_current_draft_does_nothing() + { + $post = $this->createPost(); + $post->publish(); + + $publishedPost = $post->publish(); + + $this->assertEquals($post->id, $publishedPost->id); + $this->assertTrue($post->is_published); + } + + public function test_publishing_draft_of_unpublished_post_publishes_post() + { + $post = $this->createPost(); // Not published + + $post->title = 'New Title'; + $post->save(); + + // Assert that a draft is created + $this->assertNotNull($post); + $this->assertFalse($post->is_published); + $this->assertTrue($post->is_current); + + // Publish the draft + $post->publish(); + + // Verify that the original post is now published with updated attributes + $this->assertTrue($post->is_published); + $this->assertEquals('New Title', $post->title); + $this->assertTrue($post->is_current); + } + + public function test_drafting_unpublished_post_saves_changes_directly() + { + $post = $this->createPost(['title' => 'New Title']); // Not published + + // Assert that no draft was created + $draft = $post->draftWithoutSelf(); + $this->assertNull($draft); + + // Verify that the post's attributes were updated directly + $this->assertEquals('New Title', $post->title); + $this->assertFalse($post->is_published); + $this->assertTrue($post->is_current); + } +}