diff --git a/.gitignore b/.gitignore index 8b7ef350..e2652f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /vendor composer.lock +/.idea +.phpunit.result.cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3df92b6d..b7cbd52f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contribution Guidelines Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be -opened in the github issues tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) +opened in the GitHub "Issues" tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) Please submit all pull requests to the [revisionable/develop](https://github.com/VentureCraft/revisionable/tree/develop) branch, so they can be tested before being merged into the master branch. diff --git a/composer.json b/composer.json index 52677d68..023d4c7f 100644 --- a/composer.json +++ b/composer.json @@ -1,29 +1,52 @@ -{ - "name": "venturecraft/revisionable", - "license": "MIT", - "description": "Keep a revision history for your models without thinking, created as a package for use with Laravel", - "keywords": ["model", "laravel", "ardent", "revision", "history"], - "homepage": "http://github.com/venturecraft/revisionable", - "authors": [ - { - "name": "Chris Duell", - "email": "me@chrisduell.com" - } - ], - "support": { - "issues": "https://github.com/VentureCraft/revisionable/issues", - "source": "https://github.com/VentureCraft/revisionable" - }, - "require": { - "php": ">=5.3.0", - "illuminate/support": "~4.0|~5.0|~5.1" - }, - "autoload": { - "classmap": [ - "src/migrations" - ], - "psr-0": { - "Venturecraft\\Revisionable": "src/" - } - } -} +{ + "name": "venturecraft/revisionable", + "license": "MIT", + "description": "Keep a revision history for your models without thinking, created as a package for use with Laravel", + "keywords": [ + "model", + "laravel", + "ardent", + "revision", + "audit", + "history" + ], + "homepage": "http://github.com/venturecraft/revisionable", + "authors": [ + { + "name": "Chris Duell", + "email": "me@chrisduell.com" + } + ], + "support": { + "issues": "https://github.com/VentureCraft/revisionable/issues", + "source": "https://github.com/VentureCraft/revisionable" + }, + "require": { + "php": ">=5.4.0", + "illuminate/support": "~4.0|~5.0|~5.1|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "~5.4|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" + }, + "autoload": { + "classmap": [ + "src/migrations" + ], + "psr-0": { + "Venturecraft\\Revisionable": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Venturecraft\\Revisionable\\Tests\\": "tests/" + } + }, + "require-dev": { + "orchestra/testbench": "~3.0|^8.0|^9.0|^10.0" + }, + "extra": { + "laravel": { + "providers": [ + "Venturecraft\\Revisionable\\RevisionableServiceProvider" + ] + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..f7237bf4 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + + ./tests/ + + + + + + + diff --git a/readme.md b/readme.md index 07bbab14..0e85c25b 100644 --- a/readme.md +++ b/readme.md @@ -1,13 +1,10 @@ -Revisionable +Revisionable for Laravel - - - - - - +[![Latest Version](https://img.shields.io/github/release/venturecraft/revisionable.svg?style=flat-square)](https://packagist.org/packages/venturecraft/revisionable) +[![Downloads](https://img.shields.io/packagist/dt/venturecraft/revisionable.svg?style=flat-square)](https://packagist.org/packages/venturecraft/revisionable) +[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://tldrlegal.com/license/mit-license) -Wouldn't it be nice to have a revision history for any model in your project, without having to do any work for it. By simply extending revisionable from your model, you can instantly have just that, and be able to display a history similar to this: +Wouldn't it be nice to have a revision history for any model in your project, without having to do any work for it. By simply adding the `RevisionableTrait` Trait to your model, you can instantly have just that, and be able to display a history similar to this: * Chris changed title from 'Something' to 'Something else' * Chris changed category from 'News' to 'Breaking news' @@ -23,11 +20,11 @@ Revisionable has support for Auth powered by * [**Sentry by Cartalyst**](https://cartalyst.com/manual/sentry). * [**Sentinel by Cartalyst**](https://cartalyst.com/manual/sentinel). -Revisionable can also now be used [as a trait](#the-new-trait-based-implementation), so your models can continue to extend Eloquent, or any other class that extends Eloquent (like [Ardent](https://github.com/laravelbook/ardent)). +*(Recommended)* Revisionable can also now be used [as a Trait](#the-new-trait-based-implementation), so your models can continue to extend Eloquent, or any other class that extends Eloquent (like [Ardent](https://github.com/laravelbook/ardent)). ## Installation -Revisionable is installable via [composer](http://getcomposer.org/doc/00-intro.md), the details are on [packagist, here.](https://packagist.org/packages/venturecraft/revisionable) +Revisionable is installable via [composer](https://getcomposer.org/doc/00-intro.md), the details are on [packagist, here.](https://packagist.org/packages/venturecraft/revisionable) Add the following to the `require` section of your projects composer.json file: @@ -41,15 +38,34 @@ Run composer update to download the package php composer.phar update ``` -Finally, you'll also need to run migration on the package +Open config/app.php and register the required service provider (Laravel 5.x) +``` +'providers' => [ + Venturecraft\Revisionable\RevisionableServiceProvider::class, +] +``` + +Publish the configuration and migrations (Laravel 5.x) + +``` +php artisan vendor:publish --provider="Venturecraft\Revisionable\RevisionableServiceProvider" +``` + +Finally, you'll also need to run migration on the package (Laravel 5.x) + +``` +php artisan migrate +``` + +For Laravel 4.x users: ``` php artisan migrate --package=venturecraft/revisionable ``` > If you're going to be migrating up and down completely a lot (using `migrate:refresh`), one thing you can do instead is to copy the migration file from the package to your `app/database` folder, and change the classname from `CreateRevisionsTable` to something like `CreateRevisionTable` (without the 's', otherwise you'll get an error saying there's a duplicate class) -> `cp vendor/venturecraft/revisionable/src/migrations/2013_04_09_062329_create_revisions_table.php app/database/migrations/` +> `cp vendor/venturecraft/revisionable/src/migrations/2013_04_09_062329_create_revisions_table.php database/migrations/` ## Docs @@ -64,54 +80,49 @@ php artisan migrate --package=venturecraft/revisionable ## Implementation -### The new, trait based implementation +### The new, Trait based implementation (recommended) +> Traits require PHP >= 5.4 -For any model that you want to keep a revision history for, include the revisionable namespace and use the `RevisionableTrait` in your model, e.g., -If you are using another bootable trait the be sure to override the boot method in your model; +For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g., ```php -namespace MyApp\Models; +namespace App; -class Article extends Eloquent { - use \Venturecraft\Revisionable\RevisionableTrait; +use \Venturecraft\Revisionable\RevisionableTrait; - public static function boot() - { - parent::boot(); - } +class Article extends \Illuminate\Database\Eloquent\Model { + use RevisionableTrait; } ``` -> Being a trait, revisionable can now be used with the standard Eloquent model, or any class that extends Eloquent, like [Ardent](https://github.com/laravelbook/ardent) for example. - -> Traits require PHP >= 5.4 +> Being a trait, Revisionable can now be used with the standard Eloquent model, or any class that extends Eloquent, such as [Ardent](https://github.com/laravelbook/ardent). ### Legacy class based implementation > The new trait based approach is backwards compatible with existing installations of Revisionable. You can still use the below installation instructions, which essentially is extending a wrapper for the trait. -For any model that you want to keep a revision history for, include the revisionable namespace and extend revisionable instead of eloquent, e.g., +For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g., ```php use Venturecraft\Revisionable\Revisionable; -namespace MyApp\Models; +namespace App; class Article extends Revisionable { } ``` -Note that it also works with namespaced models. +> Note: This also works with namespaced models. ### Implementation notes -If needed, you can disable the revisioning by setting `$revisionEnabled` to false in your model. This can be handy if you want to temporarily disable revisioning, or if you want to create your own base model that extends revisionable, which all of your models extend, but you want to turn revisionable off for certain models. +If needed, you can disable the revisioning by setting `$revisionEnabled` to false in your Model. This can be handy if you want to temporarily disable revisioning, or if you want to create your own base Model that extends Revisionable, which all of your models extend, but you want to turn Revisionable off for certain models. ```php -namespace MyApp\Models; +namespace App; -class Article extends Eloquent { - use Venturecraft\Revisionable\RevisionableTrait; +use \Venturecraft\Revisionable\RevisionableTrait; +class Article extends \Illuminate\Database\Eloquent\Model { protected $revisionEnabled = false; } ``` @@ -119,11 +130,11 @@ class Article extends Eloquent { You can also disable revisioning after X many revisions have been made by setting `$historyLimit` to the number of revisions you want to keep before stopping revisions. ```php -namespace MyApp\Models; +namespace App; -class Article extends Eloquent { - use Venturecraft\Revisionable\RevisionableTrait; +use \Venturecraft\Revisionable\RevisionableTrait; +class Article extends \Illuminate\Database\Eloquent\Model { protected $revisionEnabled = true; protected $historyLimit = 500; //Stop tracking revisions after 500 changes have been made. } @@ -131,20 +142,19 @@ class Article extends Eloquent { In order to maintain a limit on history, but instead of stopping tracking revisions if you want to remove old revisions, you can accommodate that feature by setting `$revisionCleanup`. ```php -namespace MyApp\Models; +namespace App; -class Article extends Eloquent { - use Venturecraft\Revisionable\RevisionableTrait; +use \Venturecraft\Revisionable\RevisionableTrait; +class Article extends \Illuminate\Database\Eloquent\Model { protected $revisionEnabled = true; protected $revisionCleanup = true; //Remove old revisions (works only when used with $historyLimit) protected $historyLimit = 500; //Maintain a maximum of 500 changes at any point of time, while cleaning up old revisions. } ``` -### Storing soft deletes - -By default, if your model supports soft deletes, revisionable will store this and any restores as updates on the model. +### Storing Soft Deletes +By default, if your model supports soft deletes, Revisionable will store this and any restores as updates on the model. You can choose to ignore deletes and restores by adding `deleted_at` to your `$dontKeepRevisionOf` array. @@ -152,7 +162,19 @@ To better format the output for `deleted_at` entries, you can use the `isEmpty` -### Storing creations +### Storing Force Delete +By default the Force Delete of a model is not stored as a revision. + +If you want to store the Force Delete as a revision you can override this behavior by setting `revisionForceDeleteEnabled ` to `true` by adding the following to your model: +```php +protected $revisionForceDeleteEnabled = true; +``` + +In which case, the `created_at` field will be stored as a key with the `oldValue()` value equal to the model creation date and the `newValue()` value equal to `null`. + +**Attention!** Turn on this setting carefully! Since the model saved in the revision, now does not exist, so you will not be able to get its object or its relations. + +### Storing Creations By default the creation of a new model is not stored as a revision. Only subsequent changes to a model is stored. @@ -161,52 +183,83 @@ If you want to store the creation as a revision you can override this behavior b protected $revisionCreationsEnabled = true; ``` -## More control +## More Control No doubt, there'll be cases where you don't want to store a revision history only for certain fields of the model, this is supported in two different ways. In your model you can either specifiy which fields you explicitly want to track and all other fields are ignored: ```php -protected $keepRevisionOf = array( - 'title' -); +protected $keepRevisionOf = ['title']; ``` Or, you can specify which fields you explicitly don't want to track. All other fields will be tracked. ```php -protected $dontKeepRevisionOf = array( - 'category_id' -); +protected $dontKeepRevisionOf = ['category_id']; +``` + +> The `$keepRevisionOf` setting takes precedence over `$dontKeepRevisionOf` + +### Storing additional fields in revisions + +In some cases, you'll want additional metadata from the models in each revision. An example of this might be if you +have to keep track of accounts as well as users. Simply create your own new migration to add the fields you'd like to your revision model, +add them to your config/revisionable.php in an array like so: + +```php +'additional_fields' => ['account_id', 'permissions_id', 'other_id'], ``` -> The `$keepRevisionOf` setting takes precendence over `$dontKeepRevisionOf` +If the column exists in the model, it will be included in the revision. + +Make sure that if you can't guarantee the column in every model, you make that column ```nullable()``` in your migrations. + + +### Events + +Every time a model revision is created an event is fired. You can listen for `revisionable.created`, +`revisionable.saved` or `revisionable.deleted`. + +```php +// app/Providers/EventServiceProvider.php + +public function boot() +{ + parent::boot(); + + $events->listen('revisionable.*', function($model, $revisions) { + // Do something with the revisions or the changed model. + dd($model, $revisions); + }); +} + +``` ## Format output -> You can continue (and are encouraged to) use `eloquent accessors` in your model to set the -output of your values, see the [laravel docs for more information on accessors](http://laravel.com/docs/eloquent-mutators#accessors-and-mutators) +> You can continue (and are encouraged to) use `Eloquent accessors` in your model to set the +output of your values, see the [Laravel Documentation for more information on accessors](https://laravel.com/docs/eloquent-mutators#accessors-and-mutators) > The below documentation is therefor deprecated In cases where you want to have control over the format of the output of the values, for example a boolean field, you can set them in the `$revisionFormattedFields` array in your model. e.g., ```php -protected $revisionFormattedFields = array( - 'title' => 'string:%s', - 'public' => 'boolean:No|Yes', - 'modified' => 'datetime:m/d/Y g:i A', +protected $revisionFormattedFields = [ + 'title' => 'string:%s', + 'public' => 'boolean:No|Yes', + 'modified' => 'datetime:m/d/Y g:i A', 'deleted_at' => 'isEmpty:Active|Deleted' -); +]; ``` You can also override the field name output using the `$revisionFormattedFieldNames` array in your model, e.g., ```php -protected $revisionFormattedFieldNames = array( - 'title' => 'Title', +protected $revisionFormattedFieldNames = [ + 'title' => 'Title', 'small_name' => 'Nickname', 'deleted_at' => 'Deleted At' -); +]; ``` This comes into play when you output the revision field name using `$revision->fieldName()` @@ -225,6 +278,14 @@ Booleans by default will display as a 0 or a 1, which is pretty bland and won't boolean:No|Yes ``` +### Options +Analogous to "boolean", only any text or numeric values can act as a source value (often flags are stored in the database). The format allows you to specify different outputs depending on the value. +Look at this as an associative array in which the key is separated from the value by a dot. Array elements are separated by a vertical line. + +``` +options:search.On the search|network.In networks +``` + ### DateTime DateTime by default will display as Y-m-d H:i:s. Prefix the value with `datetime:` and then add your datetime format, e.g., @@ -283,7 +344,7 @@ If you have enabled revisions of creations as well you can display it like this: ### userResponsible() -Returns the User that was responsible for making the revision. A user model is returned, or null if there was no user recorded. +Returns the User that was responsible for making the revision. A user model is returned, or false if there was no user recorded. The user model that is loaded depends on what you have set in your `config/auth.php` file for the `model` variable. @@ -333,14 +394,14 @@ $object->disableRevisionField('title'); // Disables title or: ```php -$object->disableRevisionField(array('title', 'content')); // Disables title and content +$object->disableRevisionField(['title', 'content']); // Disables title and content ``` ## Contributing Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be -opened in the github issues tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) +opened in the GitHub issues tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) All pull requests should be made to the develop branch, so they can be tested before being merged into the master branch. @@ -349,7 +410,7 @@ All pull requests should be made to the develop branch, so they can be tested be If you're having troubles with using this package, odds on someone else has already had the same problem. Two places you can look for common answers to your problems are: -* [StackOverflow revisionable tag](http://stackoverflow.com/questions/tagged/revisionable?sort=newest&pageSize=50) -* [Github Issues](https://github.com/VentureCraft/revisionable/issues?page=1&state=closed) +* [StackOverflow revisionable tag](https://stackoverflow.com/questions/tagged/revisionable?sort=newest&pageSize=50) +* [GitHub Issues](https://github.com/VentureCraft/revisionable/issues) > If you do prefer posting your questions to the public on StackOverflow, please use the 'revisionable' tag. diff --git a/src/Venturecraft/Revisionable/FieldFormatter.php b/src/Venturecraft/Revisionable/FieldFormatter.php index b9572c34..3cf89d88 100644 --- a/src/Venturecraft/Revisionable/FieldFormatter.php +++ b/src/Venturecraft/Revisionable/FieldFormatter.php @@ -117,4 +117,29 @@ public static function datetime($value, $format = 'Y-m-d H:i:s') return $datetime->format($format); } + + /** + * Format options + * + * @param string $value + * @param string $format + * @return string + */ + public static function options($value, $format) + { + $options = explode('|', $format); + + $result = []; + + foreach ($options as $option) { + $transform = explode('.', $option); + $result[$transform[0]] = $transform[1]; + } + + if (isset($result[$value])) { + return $result[$value]; + } + + return 'undefined'; + } } diff --git a/src/Venturecraft/Revisionable/Revision.php b/src/Venturecraft/Revisionable/Revision.php index 98d429ef..2fdc8389 100644 --- a/src/Venturecraft/Revisionable/Revision.php +++ b/src/Venturecraft/Revisionable/Revision.php @@ -2,6 +2,7 @@ namespace Venturecraft\Revisionable; +use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Model as Eloquent; use Illuminate\Support\Facades\Log; @@ -75,7 +76,7 @@ public function fieldName() */ private function formatFieldName($key) { - $related_model = $this->revisionable_type; + $related_model = $this->getActualClassNameForMorph($this->revisionable_type); $related_model = new $related_model; $revisionFormattedFieldNames = $related_model->getRevisionFormattedFieldNames(); @@ -133,14 +134,14 @@ private function getValue($which = 'new') $main_model = new $main_model; try { - if (strpos($this->key, '_id')) { - $related_model = str_replace('_id', '', $this->key); + if ($this->isRelated()) { + $related_model = $this->getRelatedModel(); // Now we can find out the namespace of of related model if (!method_exists($main_model, $related_model)) { - $related_model = camel_case($related_model); // for cases like published_status_id + $related_model = Str::camel($related_model); // for cases like published_status_id if (!method_exists($main_model, $related_model)) { - throw new \Exception('Relation ' . $related_model . ' does not exist for ' . $main_model); + throw new \Exception('Relation ' . $related_model . ' does not exist for ' . get_class($main_model)); } } $related_class = $main_model->$related_model()->getRelated(); @@ -160,25 +161,26 @@ private function getValue($which = 'new') return $this->format($this->key, $item->getRevisionUnknownString()); } + // Check if model use RevisionableTrait + if(method_exists($item, 'identifiableName')) { + // see if there's an available mutator + $mutator = 'get' . Str::studly($this->key) . 'Attribute'; + if (method_exists($item, $mutator)) { + return $this->format($item->$mutator($this->key), $item->identifiableName()); + } - // see if there's an available mutator - $mutator = 'get' . studly_case($this->key) . 'Attribute'; - if (method_exists($item, $mutator)) { - return $this->format($item->$mutator($this->key), $item->identifiableName()); + return $this->format($this->key, $item->identifiableName()); } - - return $this->format($this->key, $item->identifiableName()); } } catch (\Exception $e) { // Just a fail-safe, in the case the data setup isn't as expected // Nothing to do here. - Log::info('Revisionable: ' . $e); } // if there was an issue // or, if it's a normal value - $mutator = 'get' . studly_case($this->key) . 'Attribute'; + $mutator = 'get' . Str::studly($this->key) . 'Attribute'; if (method_exists($main_model, $mutator)) { return $this->format($this->key, $main_model->$mutator($this->$which_value)); } @@ -187,6 +189,38 @@ private function getValue($which = 'new') return $this->format($this->key, $this->$which_value); } + /** + * Return true if the key is for a related model. + * + * @return bool + */ + private function isRelated() + { + $isRelated = false; + $idSuffix = '_id'; + $pos = strrpos($this->key, $idSuffix); + + if ($pos !== false + && strlen($this->key) - strlen($idSuffix) === $pos + ) { + $isRelated = true; + } + + return $isRelated; + } + + /** + * Return the name of the related model. + * + * @return string + */ + private function getRelatedModel() + { + $idSuffix = '_id'; + + return substr($this->key, 0, strlen($this->key) - strlen($idSuffix)); + } + /** * User Responsible. * @@ -246,7 +280,7 @@ public function historyOf() */ public function format($key, $value) { - $related_model = $this->revisionable_type; + $related_model = $this->getActualClassNameForMorph($this->revisionable_type); $related_model = new $related_model; $revisionFormattedFields = $related_model->getRevisionFormattedFields(); diff --git a/src/Venturecraft/Revisionable/Revisionable.php b/src/Venturecraft/Revisionable/Revisionable.php index d78de8d0..5134af12 100644 --- a/src/Venturecraft/Revisionable/Revisionable.php +++ b/src/Venturecraft/Revisionable/Revisionable.php @@ -1,5 +1,6 @@ postSave(); }); - static::created(function($model){ + static::created(function ($model) { $model->postCreate(); }); static::deleted(function ($model) { $model->preSave(); $model->postDelete(); + $model->postForceDelete(); }); } + /** + * Instance the revision model + * @return \Illuminate\Database\Eloquent\Model + */ + public static function newModel() + { + $model = app('config')->get('revisionable.model'); + + if (! $model) { + $model = 'Venturecraft\Revisionable\Revision'; + } + + return new $model; + } /** * @return mixed */ public function revisionHistory() { - return $this->morphMany('\Venturecraft\Revisionable\Revision', 'revisionable'); + return $this->morphMany(get_class(static::newModel()), 'revisionable'); } /** @@ -107,11 +123,11 @@ public function preSave() // the below is ugly, for sure, but it's required so we can save the standard model // then use the keep / dontkeep values for later, in the isRevisionable method $this->dontKeep = isset($this->dontKeepRevisionOf) ? - $this->dontKeepRevisionOf + $this->dontKeep + array_merge($this->dontKeepRevisionOf, $this->dontKeep) : $this->dontKeep; $this->doKeep = isset($this->keepRevisionOf) ? - $this->keepRevisionOf + $this->doKeep + array_merge($this->keepRevisionOf, $this->doKeep) : $this->doKeep; unset($this->attributes['dontKeepRevisionOf']); @@ -141,19 +157,19 @@ public function postSave() foreach ($changes_to_record as $key => $change) { $revisions[] = array( - 'revisionable_type' => get_class($this), + 'revisionable_type' => $this->getMorphClass(), 'revisionable_id' => $this->getKey(), 'key' => $key, - 'old_value' => array_get($this->originalData, $key), + 'old_value' => Arr::get($this->originalData, $key), 'new_value' => $this->updatedData[$key], - 'user_id' => $this->getUserId(), + 'user_id' => $this->getSystemUserId(), 'created_at' => new \DateTime(), 'updated_at' => new \DateTime(), ); } if (count($revisions) > 0) { - $revision = new Revision; + $revision = static::newModel(); \DB::table($revision->getTable())->insert($revisions); } } @@ -176,19 +192,18 @@ public function postCreate() if ((!isset($this->revisionEnabled) || $this->revisionEnabled)) { $revisions[] = array( - 'revisionable_type' => get_class($this), + 'revisionable_type' => $this->getMorphClass(), 'revisionable_id' => $this->getKey(), - 'key' => 'created_at', + 'key' => self::CREATED_AT, 'old_value' => null, - 'new_value' => $this->created_at, - 'user_id' => $this->getUserId(), + 'new_value' => $this->{self::CREATED_AT}, + 'user_id' => $this->getSystemUserId(), 'created_at' => new \DateTime(), 'updated_at' => new \DateTime(), ); - $revision = new Revision; + $revision = static::newModel(); \DB::table($revision->getTable())->insert($revisions); - } } @@ -199,19 +214,50 @@ public function postDelete() { if ((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->isSoftDelete() - && $this->isRevisionable('deleted_at')) { + && $this->isRevisionable($this->getDeletedAtColumn())) { $revisions[] = array( - 'revisionable_type' => get_class($this), + 'revisionable_type' => $this->getMorphClass(), 'revisionable_id' => $this->getKey(), - 'key' => 'deleted_at', + 'key' => $this->getDeletedAtColumn(), 'old_value' => null, - 'new_value' => $this->deleted_at, - 'user_id' => $this->getUserId(), + 'new_value' => $this->{$this->getDeletedAtColumn()}, + 'user_id' => $this->getSystemUserId(), 'created_at' => new \DateTime(), 'updated_at' => new \DateTime(), ); - $revision = new \Venturecraft\Revisionable\Revision; + $revision = static::newModel(); + \DB::table($revision->getTable())->insert($revisions); + } + } + + /** + * If forcedeletes are enabled, set the value created_at of model to null + * + * @return void|bool + */ + public function postForceDelete() + { + if (empty($this->revisionForceDeleteEnabled)) { + return false; + } + + if ((!isset($this->revisionEnabled) || $this->revisionEnabled) + && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) { + + $revisions[] = array( + 'revisionable_type' => $this->getMorphClass(), + 'revisionable_id' => $this->getKey(), + 'key' => self::CREATED_AT, + 'old_value' => $this->{self::CREATED_AT}, + 'new_value' => null, + 'user_id' => $this->getSystemUserId(), + 'created_at' => new \DateTime(), + 'updated_at' => new \DateTime(), + ); + + $revision = Revisionable::newModel(); \DB::table($revision->getTable())->insert($revisions); + \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); } } @@ -219,12 +265,14 @@ public function postDelete() * Attempt to find the user id of the currently logged in user * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth **/ - private function getUserId() + private function getSystemUserId() { try { if (class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')) { return ($class::check()) ? $class::getUser()->id : null; + } elseif (function_exists('backpack_auth') && backpack_auth()->check()) { + return backpack_user()->id; } elseif (\Auth::check()) { return \Auth::user()->getAuthIdentifier(); } diff --git a/src/Venturecraft/Revisionable/RevisionableServiceProvider.php b/src/Venturecraft/Revisionable/RevisionableServiceProvider.php new file mode 100644 index 00000000..fb9fec26 --- /dev/null +++ b/src/Venturecraft/Revisionable/RevisionableServiceProvider.php @@ -0,0 +1,42 @@ +publishes([ + __DIR__ . '/../../config/revisionable.php' => config_path('revisionable.php'), + ], 'config'); + + $this->publishes([ + __DIR__ . '/../../migrations/' => database_path('migrations'), + ], 'migrations'); + } + + /** + * Register the application services. + * + * @return void + */ + public function register() + { + } + + /** + * Get the services provided by the provider. + * + * @return string[] + */ + public function provides() + { + } +} diff --git a/src/Venturecraft/Revisionable/RevisionableTrait.php b/src/Venturecraft/Revisionable/RevisionableTrait.php index deae40bb..bcb86193 100644 --- a/src/Venturecraft/Revisionable/RevisionableTrait.php +++ b/src/Venturecraft/Revisionable/RevisionableTrait.php @@ -1,12 +1,17 @@ - - * - */ use App\Repositories\Revision\RevisionRepository; +use Illuminate\Support\Arr; +use Carbon\Carbon; + +/* + * This file is part of the Revisionable package by Venture Craft + * + * (c) Venture Craft + * + */ /** * Class RevisionableTrait @@ -17,12 +22,12 @@ trait RevisionableTrait /** * @var array */ - private $originalData = []; + private $originalData = array(); /** * @var array */ - private $updatedData = []; + private $updatedData = array(); /** * @var boolean @@ -32,19 +37,19 @@ trait RevisionableTrait /** * @var array */ - private $dontKeep = []; + private $dontKeep = array(); /** * @var array */ - private $doKeep = []; + private $doKeep = array(); /** * Keeps the list of values that have been updated * * @var array */ - protected $dirtyData = []; + protected $dirtyData = array(); /** * Ensure that the bootRevisionableTrait is called only @@ -55,7 +60,7 @@ public static function boot() { parent::boot(); - if ( ! method_exists(get_called_class(), 'bootTraits')) { + if (!method_exists(get_called_class(), 'bootTraits')) { static::bootRevisionableTrait(); } } @@ -83,6 +88,7 @@ public static function bootRevisionableTrait() static::deleted(function ($model) { $model->preSave(); $model->postDelete(); + $model->postForceDelete(); }); } @@ -91,19 +97,21 @@ public static function bootRevisionableTrait() */ public function revisionHistory() { - return $this->morphMany('\Venturecraft\Revisionable\Revision', 'revisionable'); + return $this->morphMany(get_class(Revisionable::newModel()), 'revisionable'); } /** * Generates a list of the last $limit revisions made to any objects of the class it is being called from. * - * @param int $limit + * @param int $limit * @param string $order * @return mixed */ public static function classRevisionHistory($limit = 100, $order = 'desc') { - return \Venturecraft\Revisionable\Revision::where('revisionable_type', get_called_class())->orderBy('updated_at', $order)->limit($limit)->get(); + $model = Revisionable::newModel(); + return $model->where('revisionable_type', get_called_class()) + ->orderBy('updated_at', $order)->limit($limit)->get(); } /** @@ -113,16 +121,31 @@ public static function classRevisionHistory($limit = 100, $order = 'desc') */ public function preSave() { - if ( ! isset($this->revisionEnabled) || $this->revisionEnabled) { + if (!isset($this->revisionEnabled) || $this->revisionEnabled) { // if there's no revisionEnabled. Or if there is, if it's true - $this->originalData = $this->original; - $this->updatedData = $this->attributes; + $this->updatedData = $this->attributes; - // we can only safely compare basic items, - // so for now we drop any object based items, like DateTime foreach ($this->updatedData as $key => $val) { - if (gettype($val) == 'object' && ! method_exists($val, '__toString')) { + // Handle changed date attributes + if ($this->changedAttributeIsADateType($key)) { + $this->normalizeDatesForRevisionCheck($key); + } + + $castCheck = ['object', 'array']; + if (isset($this->casts[$key]) + && in_array(gettype($val), $castCheck) + && in_array($this->casts[$key], $castCheck) + && isset($this->originalData[$key]) + ) { + // Sorts the keys of a JSON object due Normalization performed by MySQL + // So it doesn't set false flag if it is changed only order of key or whitespace after comma + + $updatedData = $this->sortJsonKeys(json_decode($this->updatedData[$key], true)); + + $this->updatedData[$key] = json_encode($updatedData); + $this->originalData[$key] = json_encode(json_decode($this->originalData[$key], true)); + } elseif (gettype($val) == 'object' && !method_exists($val, '__toString')) { unset($this->originalData[$key]); unset($this->updatedData[$key]); array_push($this->dontKeep, $key); @@ -131,15 +154,19 @@ public function preSave() // the below is ugly, for sure, but it's required so we can save the standard model // then use the keep / dontkeep values for later, in the isRevisionable method - $this->dontKeep = isset($this->dontKeepRevisionOf) ? $this->dontKeepRevisionOf + $this->dontKeep : $this->dontKeep; + $this->dontKeep = isset($this->dontKeepRevisionOf) ? + array_merge($this->dontKeepRevisionOf, $this->dontKeep) + : $this->dontKeep; - $this->doKeep = isset($this->keepRevisionOf) ? $this->keepRevisionOf + $this->doKeep : $this->doKeep; + $this->doKeep = isset($this->keepRevisionOf) ? + array_merge($this->keepRevisionOf, $this->doKeep) + : $this->doKeep; unset($this->attributes['dontKeepRevisionOf']); unset($this->attributes['keepRevisionOf']); $this->dirtyData = $this->getDirty(); - $this->updating = $this->exists; + $this->updating = $this->exists; } } @@ -152,46 +179,49 @@ public function preSave() public function postSave() { if (isset($this->historyLimit) && $this->revisionHistory()->count() >= $this->historyLimit) { - $LimitReached = true; + $limitReached = true; } else { - $LimitReached = false; + $limitReached = false; } if (isset($this->revisionCleanup)) { - $RevisionCleanup = $this->revisionCleanup; + $revisionCleanup = $this->revisionCleanup; } else { - $RevisionCleanup = false; + $revisionCleanup = false; } // check if the model already exists - if ((( ! isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) && ( ! $LimitReached || $RevisionCleanup)) { + if (((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) && (!$limitReached || $revisionCleanup)) { // if it does, it means we're updating - $changes_to_record = $this->changedRevisionableFields(); - - $revisions = []; - - foreach ($changes_to_record as $key => $change) { - $revisions[] = [ - 'revisionable_type' => get_class($this), - 'revisionable_id' => $this->getKey(), - 'key' => $key, - 'old_value' => array_get($this->originalData, $key), - 'new_value' => $this->updatedData[$key], - 'user_id' => $this->getUserId(), - 'created_at' => new \DateTime(), - 'updated_at' => new \DateTime(), - ] + app(RevisionRepository::class)->getExtraAttributes(); + $changesToRecord = $this->changedRevisionableFields(); + + $revisions = array(); + + foreach ($changesToRecord as $key => $change) { + $original = [ + 'revisionable_type' => $this->getMorphClass(), + 'revisionable_id' => $this->getKey(), + 'key' => $key, + 'old_value' => Arr::get($this->originalData, $key), + 'new_value' => $this->updatedData[$key], + 'user_id' => $this->getSystemUserId(), + 'created_at' => new \DateTime(), + 'updated_at' => new \DateTime(), + ] + app(RevisionRepository::class)->getExtraAttributes(); + + $revisions[] = array_merge($original, $this->getAdditionalFields()); } if (count($revisions) > 0) { - if ($LimitReached && $RevisionCleanup) { + if ($limitReached && $revisionCleanup) { $toDelete = $this->revisionHistory()->orderBy('id', 'asc')->limit(count($revisions))->get(); foreach ($toDelete as $delete) { $delete->delete(); } } - $revision = new Revision; - \DB::table($revision->getTable())->insert($revisions); + $revision = Revisionable::newModel(); + \DB::connection($revision->getConnectionName())->table($revision->getTable())->insert($revisions); + \Event::dispatch('revisionable.saved', array('model' => $this, 'revisions' => $revisions)); } } } @@ -201,7 +231,6 @@ public function postSave() */ public function postCreate() { - // Check if we should store creations in our revision history // Set this value to true in your model if you want to if (empty($this->revisionCreationsEnabled)) { @@ -209,24 +238,27 @@ public function postCreate() return false; } - if (( ! isset($this->revisionEnabled) || $this->revisionEnabled)) { - $revisions[] = [ - 'revisionable_type' => get_class($this), + if ((!isset($this->revisionEnabled) || $this->revisionEnabled)) { + $revisions[] = array( + 'revisionable_type' => $this->getMorphClass(), 'revisionable_id' => $this->getKey(), - 'key' => 'created_at', + 'key' => self::CREATED_AT, 'old_value' => null, - 'new_value' => $this->created_at, - 'user_id' => $this->getUserId(), + 'new_value' => $this->{self::CREATED_AT}, + 'user_id' => $this->getSystemUserId(), 'created_at' => new \DateTime(), 'updated_at' => new \DateTime(), - ]; + ); - $revision = new Revision; - \DB::table($revision->getTable())->insert($revisions); + //Determine if there are any additional fields we'd like to add to our model contained in the config file, and + //get them into an array. + $revisions = array_merge($revisions[0], $this->getAdditionalFields()); + $revision = Revisionable::newModel(); + \DB::connection($revision->getConnectionName())->table($revision->getTable())->insert($revisions); + \Event::dispatch('revisionable.created', array('model' => $this, 'revisions' => $revisions)); } - } /** @@ -234,19 +266,58 @@ public function postCreate() */ public function postDelete() { - if (( ! isset($this->revisionEnabled) || $this->revisionEnabled) && $this->isSoftDelete() && $this->isRevisionable('deleted_at')) { - $revisions[] = [ - 'revisionable_type' => get_class($this), + if ((!isset($this->revisionEnabled) || $this->revisionEnabled) + && $this->isSoftDelete() + && $this->isRevisionable($this->getDeletedAtColumn()) + ) { + $revisions[] = array( + 'revisionable_type' => $this->getMorphClass(), 'revisionable_id' => $this->getKey(), - 'key' => 'deleted_at', + 'key' => $this->getDeletedAtColumn(), 'old_value' => null, - 'new_value' => $this->deleted_at, - 'user_id' => $this->getUserId(), + 'new_value' => $this->{$this->getDeletedAtColumn()}, + 'user_id' => $this->getSystemUserId(), 'created_at' => new \DateTime(), 'updated_at' => new \DateTime(), - ]; - $revision = new \Venturecraft\Revisionable\Revision; - \DB::table($revision->getTable())->insert($revisions); + ); + + //Since there is only one revision because it's deleted, let's just merge into revision[0] + $revisions = array_merge($revisions[0], $this->getAdditionalFields()); + + $revision = Revisionable::newModel(); + \DB::connection($revision->getConnectionName())->table($revision->getTable())->insert($revisions); + \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); + } + } + + /** + * If forcedeletes are enabled, set the value created_at of model to null + * + * @return void|bool + */ + public function postForceDelete() + { + if (empty($this->revisionForceDeleteEnabled)) { + return false; + } + + if ((!isset($this->revisionEnabled) || $this->revisionEnabled) + && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) { + + $revisions[] = array( + 'revisionable_type' => $this->getMorphClass(), + 'revisionable_id' => $this->getKey(), + 'key' => self::CREATED_AT, + 'old_value' => $this->{self::CREATED_AT}, + 'new_value' => null, + 'user_id' => $this->getSystemUserId(), + 'created_at' => new \DateTime(), + 'updated_at' => new \DateTime(), + ); + + $revision = Revisionable::newModel(); + \DB::connection($revision->getConnectionName())->table($revision->getTable())->insert($revisions); + \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); } } @@ -254,21 +325,44 @@ public function postDelete() * Attempt to find the user id of the currently logged in user * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth **/ - public function getUserId() + public function getSystemUserId() { try { - if (class_exists($class = '\SleepingOwl\AdminAuth\Facades\AdminAuth') || class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')) { + if (class_exists($class = '\SleepingOwl\AdminAuth\Facades\AdminAuth') + || class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') + || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel') + ) { return ($class::check()) ? $class::getUser()->id : null; - } elseif (\Auth::check()) { + } + if (function_exists('backpack_auth') && backpack_auth()->check()) { + return backpack_user()->id; + } + if (\Auth::check()) { return \Auth::user()->getAuthIdentifier(); } - } catch ( \Exception $e ) { + } catch (\Exception $e) { return null; } return null; } + + public function getAdditionalFields() + { + $additional = []; + //Determine if there are any additional fields we'd like to add to our model contained in the config file, and + //get them into an array. + $fields = config('revisionable.additional_fields', []); + foreach ($fields as $field) { + if (Arr::has($this->originalData, $field)) { + $additional[$field] = Arr::get($this->originalData, $field); + } + } + + return $additional; + } + /** * Get all of the changes that have been made, that are also supposed * to have their changes recorded @@ -277,13 +371,13 @@ public function getUserId() */ private function changedRevisionableFields() { - $changes_to_record = []; + $changesToRecord = array(); foreach ($this->dirtyData as $key => $value) { // check that the field is revisionable, and double check // that it's actually new data in case dirty is, well, clean - if ($this->isRevisionable($key) && ! is_array($value)) { - if ( ! isset($this->originalData[$key]) || $this->originalData[$key] != $this->updatedData[$key]) { - $changes_to_record[$key] = $value; + if ($this->isRevisionable($key) && !is_array($value)) { + if (!array_key_exists($key, $this->originalData) || $this->originalData[$key] != $this->updatedData[$key]) { + $changesToRecord[$key] = $value; } } else { // we don't need these any more, and they could @@ -293,7 +387,7 @@ private function changedRevisionableFields() } } - return $changes_to_record; + return $changesToRecord; } /** @@ -305,7 +399,6 @@ private function changedRevisionableFields() */ private function isRevisionable($key) { - // If the field is explicitly revisionable, then return true. // If it's explicitly not revisionable, return false. // Otherwise, if neither condition is met, only return true if @@ -329,7 +422,7 @@ private function isSoftDelete() { // check flag variable used in laravel 4.2+ if (isset($this->forceDeleting)) { - return ! $this->forceDeleting; + return !$this->forceDeleting; } // otherwise, look for flag used in older versions @@ -408,18 +501,99 @@ public function getRevisionUnknownString() */ public function disableRevisionField($field) { - if ( ! isset($this->dontKeepRevisionOf)) { - $this->dontKeepRevisionOf = []; + if (!isset($this->dontKeepRevisionOf)) { + $this->dontKeepRevisionOf = array(); } if (is_array($field)) { foreach ($field as $one_field) { $this->disableRevisionField($one_field); } } else { - $donts = $this->dontKeepRevisionOf; - $donts[] = $field; + $donts = $this->dontKeepRevisionOf; + $donts[] = $field; $this->dontKeepRevisionOf = $donts; unset($donts); } } + + /** + * Sorts the keys of a JSON object + * + * Normalization performed by MySQL and + * discards extra whitespace between keys, values, or elements + * in the original JSON document. + * To make lookups more efficient, it sorts the keys of a JSON object. + * + * @param mixed $attribute + * + * @return mixed + */ + private function sortJsonKeys($attribute) + { + if (empty($attribute)) { + return $attribute; + } + + foreach ($attribute as $key => $value) { + if (is_array($value) || is_object($value)) { + $value = $this->sortJsonKeys($value); + } else { + continue; + } + + ksort($value); + $attribute[$key] = $value; + } + + return $attribute; + } + + private function normalizeDatesForRevisionCheck(string $key) + { + $originalValue = $this->originalData[$key] ?? null; + $updatedValue = $this->updatedData[$key] ?? null; + + // Return if new or old values are null + if ($originalValue === null || $updatedValue === null) { + return; + } + + // If original data is a date string (Y-m-d), cast values to date string for comparison + if (is_string($originalValue) + && $this->isStandardDateFormat($originalValue) + || $this->castIsDate($key)) { + + try { + $carbon = $this->asDateTime($updatedValue); + + // Normalize values to a date only string + $this->updatedData[$key] = $carbon->timezone('UTC')->toDateString(); + $this->originalData[$key] = $this->asDateTime($originalValue)->toDateString(); + + } catch (\Throwable $e) { + // If parsing fails, fall back to default revision logic + } + + return; + } + } + + private function changedAttributeIsADateType(string $key): bool + { + if (!isset($this->originalData[$key])) { + return false; + } + + $value = $this->updatedData[$key]; + + return $value instanceof Carbon + || $this->isDateAttribute($key) + || $this->isDateCastableWithCustomFormat($key); + } + + + private function castIsDate(string $key): bool + { + return isset($this->casts[$key]) && $this->casts[$key] === 'date'; + } } diff --git a/src/config/revisionable.php b/src/config/revisionable.php new file mode 100644 index 00000000..32e590ce --- /dev/null +++ b/src/config/revisionable.php @@ -0,0 +1,13 @@ + Venturecraft\Revisionable\Revision::class, + + 'additional_fields' => [], + +]; diff --git a/src/migrations/2013_04_09_062329_create_revisions_table.php b/src/migrations/2013_04_09_062329_create_revisions_table.php index fdd5b1e8..8d3d2d2a 100644 --- a/src/migrations/2013_04_09_062329_create_revisions_table.php +++ b/src/migrations/2013_04_09_062329_create_revisions_table.php @@ -12,10 +12,10 @@ class CreateRevisionsTable extends Migration public function up() { Schema::create('revisions', function ($table) { - $table->increments('id'); + $table->bigIncrements('id'); $table->string('revisionable_type'); - $table->integer('revisionable_id'); - $table->integer('user_id')->nullable(); + $table->unsignedBigInteger('revisionable_id'); + $table->unsignedBigInteger('user_id')->nullable(); $table->string('key'); $table->text('old_value')->nullable(); $table->text('new_value')->nullable(); @@ -32,6 +32,6 @@ public function up() */ public function down() { - Schema::drop('revisions'); + Schema::dropIfExists('revisions'); } } diff --git a/tests/DateRevisionTest.php b/tests/DateRevisionTest.php new file mode 100644 index 00000000..63568f1b --- /dev/null +++ b/tests/DateRevisionTest.php @@ -0,0 +1,234 @@ +loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-12-18', + 'password' => \Hash::make('456'), + ]); + + $user->fresh(); + + // Change date + $user->update([ + 'date' => '2025-12-19' + ]); + + // we should have 1 revision to the date + $this->assertCount(1, $user->revisionHistory); + $this->assertEquals('2025-12-18', $user->revisionHistory->first()['old_value']); + } + + + #[Test] + public function revision_is_stored_when_date_attribute_is_carbon() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-12-18', + 'password' => \Hash::make('456'), + ]); + + $user->fresh(); + + // Change date + $user->update([ + 'date' => Carbon::parse('2025-12-19'), + ]); + + // we should have 1 revision to the date + $this->assertCount(1, $user->revisionHistory); + $this->assertEquals('2025-12-19', $user->revisionHistory->first()['new_value']); + } + + + #[Test] + public function revision_is_stored_when_date_attribute_is_date_time_string() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = new User(); + + $user->mergeCasts([ + 'date' => 'datetime', + ]); + + $user->fill([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-12-18', + 'password' => \Hash::make('456'), + ]); + + $user->save(); + + $user->fresh(); + + // Change date + $user->update([ + 'date' => '2025-12-19 01:00:00', + ]); + + // we should have 1 revision to the date + $this->assertCount(1, $user->revisionHistory); + } + + #[Test] + public function revision_is_not_stored_when_date_attribute_is_carbon_but_date_is_same() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-12-18', + 'password' => \Hash::make('456'), + ]); + + $user->fresh(); + + // Change date + $user->update([ + 'date' => Carbon::parse('2025-12-18'), + ]); + + // we should have no revisions to the date + $this->assertCount(0, $user->revisionHistory); + } + + + #[Test] + public function revision_is_not_stored_when_date_attribute_is_datetime_string_but_date_is_same() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = new User(); + + $user->mergeCasts([ + 'date' => 'date', + ]); + + $user->fill([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-12-18', + 'password' => \Hash::make('456'), + ]); + + $user->save(); + + $user->fresh(); + + // Change date + $user->update([ + 'date' => '2025-12-18 23:59:59', + ]); + + // we should have no revisions to the date + $this->assertCount(0, $user->revisionHistory); + } + + #[Test] + public function revision_is_not_stored_when_a_custom_cast_datetime_object_in_a_different_timezone_is_set() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = new User(); + + $user->mergeCasts([ + 'date' => 'date:Y-m-d', + ]); + + $user->fill([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2025-04-01', + 'password' => \Hash::make('456'), + ]); + + $user->save(); + + $user->fresh(); + + // Create a datetime object that represents the same date in a different timezone + $dateTimeInNonUtcTimezone = Carbon::parse('2025-03-31 20:00:00.0', 'America/New_York'); + + $user->update([ + 'date' => $dateTimeInNonUtcTimezone + ]); + + // we should have no revisions to the date + $this->assertCount(0, $user->revisionHistory); + } + + + #[Test] + public function revision_is_not_stored_when_a_custom_cast_datetime_string_in_a_different_timezone_is_set() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $user = new User(); + + $user->mergeCasts([ + 'date' => 'date:Y-m-d', + ]); + + $user->fill([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'date' => '2026-07-30', + 'password' => \Hash::make('456'), + ]); + + $user->save(); + + $user->fresh(); + + // Create a datetime string that represents the same date in a different timezone + $dateTimeInNonUtcTimezone = "2026-07-31T00:00:00+01:00"; + + $user->update([ + 'date' => $dateTimeInNonUtcTimezone + ]); + + // we should have no revisions to the date + $this->assertCount(0, $user->revisionHistory); + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 00000000..1bbc835d --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,20 @@ + 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'password' => \Hash::make('456'), + ]); + + $users = User::findOrFail(1); + $this->assertEquals('james.judd@revisionable.test', $users->email); + $this->assertTrue(\Hash::check('456', $users->password)); + } + + /** + * Make sure revisions are created + */ + public function testRevisionsStored() + { + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'password' => \Hash::make('456'), + ]); + + // change to my nickname + $user->update([ + 'name' => 'Judd' + ]); + + // change to my forename + $user->update([ + 'name' => 'James' + ]); + + // we should have two revisions to my name + $this->assertCount(2, $user->revisionHistory); + } + + /** + * Make sure additional fields are saved with revision + */ + public function testRevisionStoredAdditionalFields() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $this->app['config']->set('revisionable.additional_fields', ['additional_field']); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'additional_field' => 678, + 'password' => \Hash::make('456'), + ]); + + + // change to my nickname + $user->update([ + 'name' => 'Judd' + ]); + + // we should have two revisions to my name + $this->assertCount(1, $user->revisionHistory); + + $this->assertEquals(678, $user->revisionHistory->first()->additional_field); + } + + /** + * Make sure additional fields without values don't break + */ + public function testRevisionSkipsAdditionalFieldsWhenNotAvailable() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $this->app['config']->set('revisionable.additional_fields', ['additional_field']); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'password' => \Hash::make('456'), + ]); + + + // change to my nickname + $user->update([ + 'name' => 'Judd' + ]); + + // we should have two revisions to my name + $this->assertCount(1, $user->revisionHistory); + + $this->assertNull($user->revisionHistory->first()->additional_field); + } + + /** + * Make sure additional fields which don't exist on the model still save revision + */ + public function testRevisionSkipsAdditionalFieldsWhenMisconfigured() + { + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/migrations'), + ]); + + $this->app['config']->set('revisionable.additional_fields', ['unknown_field']); + + $user = User::create([ + 'name' => 'James Judd', + 'email' => 'james.judd@revisionable.test', + 'password' => \Hash::make('456'), + ]); + + + // change to my nickname + $user->update([ + 'name' => 'Judd' + ]); + + // we should have two revisions to my name + $this->assertCount(1, $user->revisionHistory); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..8a886b3c --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,52 @@ +loadLaravelMigrations(['--database' => 'testbench']); + + // call migrations specific to our tests, e.g. to seed the db + // the path option should be an absolute path. + $this->loadMigrationsFrom([ + '--database' => 'testbench', + '--path' => realpath(__DIR__.'/../src/migrations'), + ]); + + // Bind mock RevisionRepository Class + $this->app->singleton(\App\Repositories\Revision\RevisionRepository::class, function () { + return new class { + public function getExtraAttributes(): array { + return []; + } + }; + }); + } + + /** + * Define environment setup. + * + * @param \Illuminate\Foundation\Application $app + * @return void + */ + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', array( + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + )); + } +} diff --git a/tests/migrations/2020_01_02_062329_add_additional_field_to_revisions.php b/tests/migrations/2020_01_02_062329_add_additional_field_to_revisions.php new file mode 100644 index 00000000..389ed10e --- /dev/null +++ b/tests/migrations/2020_01_02_062329_add_additional_field_to_revisions.php @@ -0,0 +1,29 @@ +string('additional_field')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} diff --git a/tests/migrations/2020_01_02_062330_add_additional_field_to_users.php b/tests/migrations/2020_01_02_062330_add_additional_field_to_users.php new file mode 100644 index 00000000..bf695f42 --- /dev/null +++ b/tests/migrations/2020_01_02_062330_add_additional_field_to_users.php @@ -0,0 +1,29 @@ +string('additional_field')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} diff --git a/tests/migrations/2025_12_18_062330_add_date_field_to_users.php b/tests/migrations/2025_12_18_062330_add_date_field_to_users.php new file mode 100644 index 00000000..482ef9b4 --- /dev/null +++ b/tests/migrations/2025_12_18_062330_add_date_field_to_users.php @@ -0,0 +1,31 @@ +date('date')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('date'); + }); + } +}