├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Mpociot │ └── Versionable │ │ ├── Providers │ │ └── ServiceProvider.php │ │ ├── Version.php │ │ └── VersionableTrait.php ├── config │ └── config.php └── migrations │ └── 2014_09_27_212641_create_versions_table.php └── tests ├── VersionableTest.php └── VersionableTestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | composer.lock 3 | /vendor/ 4 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 8.0 5 | - 8.1 6 | - 8.2 7 | 8 | before_script: 9 | - travis_retry composer self-update 10 | - travis_retry composer install --prefer-source --no-interaction 11 | 12 | script: 13 | - vendor/bin/phpunit --coverage-clover=coverage.xml 14 | 15 | before_install: 16 | - pip install --user codecov 17 | after_success: 18 | - codecov -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Marcel Pociot 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Versionable 2 | 3 | ## Laravel Model versioning made easy 4 | 5 | ![image](http://img.shields.io/packagist/v/mpociot/versionable.svg?style=flat) 6 | ![image](http://img.shields.io/packagist/l/mpociot/versionable.svg?style=flat) 7 | ![image](http://img.shields.io/packagist/dt/mpociot/versionable.svg?style=flat) 8 | [![codecov.io](https://codecov.io/github/mpociot/versionable/coverage.svg?branch=master)](https://codecov.io/github/mpociot/versionable?branch=master) 9 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/mpociot/versionable/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/mpociot/versionable/?branch=master) 10 | [![Build Status](https://travis-ci.org/mpociot/versionable.svg?branch=master)](https://travis-ci.org/mpociot/versionable) 11 | 12 | Keep track of all your model changes and revert to previous versions of it. 13 | 14 | ```php 15 | // Restore to the previous change 16 | $content->previousVersion()->revert(); 17 | 18 | // Get model from a version 19 | $oldModel = Version::find(100)->getModel(); 20 | ``` 21 | 22 | 23 | 24 | ## Installation 25 | 26 | You can install via composer: 27 | 28 | ``` 29 | composer require mpociot/versionable 30 | ``` 31 | 32 | Run the migrations. 33 | 34 | ``` 35 | php artisan migrate --path=vendor/mpociot/versionable/src/migrations 36 | ``` 37 | 38 | Alternatively, publish the migrations. 39 | 40 | ``` 41 | php artisan vendor:publish --provider="Mpociot\Versionable\Providers\ServiceProvider" --tag="migrations" 42 | ``` 43 | 44 | Then customize and run them. 45 | 46 | ``` 47 | php artisan migrate 48 | ``` 49 | 50 | 51 | 52 | ## Usage 53 | 54 | Let the Models you want to set under version control use the `VersionableTrait`. 55 | 56 | ```php 57 | class Content extends Model { 58 | 59 | use Mpociot\Versionable\VersionableTrait; 60 | 61 | } 62 | ``` 63 | 64 | That's it! 65 | 66 | Every time you update your model, a new version containing the previous attributes will be stored in your database. 67 | 68 | All timestamps and the optional soft-delete timestamp will be ignored. 69 | 70 | 71 | 72 | ### Adding versions to existing data 73 | 74 | Versionable creates a version on update() of the *updated* model. So, if you're installing this on an already existing application, you may want to create a version of the current model: 75 | 76 | ```php 77 | $model->createInitialVersion(); 78 | ``` 79 | If no version exists, this will create the initial version. 80 | 81 | If you want to do this for all instances of a model: 82 | 83 | ```php 84 | Model::initializeVersions(); 85 | ``` 86 | 87 | 88 | 89 | ### Exclude attributes from versioning 90 | 91 | Sometimes you don't want to create a version *every* time an attribute on your model changes. For example your User model might have a `last_login_at` attribute. 92 | I'm pretty sure you don't want to create a new version of your User model every time that user logs in. 93 | 94 | To exclude specific attributes from versioning, add a new array property to your model named `dontVersionFields`. 95 | 96 | ```php 97 | class User extends Model { 98 | 99 | use Mpociot\Versionable\VersionableTrait; 100 | 101 | /** 102 | * @var array 103 | */ 104 | protected $dontVersionFields = [ 'last_login_at' ]; 105 | 106 | } 107 | ``` 108 | 109 | 110 | 111 | ### Hidden fields 112 | 113 | There are times you might want to include hidden fields in the version data. You might have hidden the fields with the `visible` or `hidden` properties in your model. 114 | 115 | You can have those fields that are typically hidden in the rest of your project saved in the version data by adding them to the `versionedHiddenFields` property of the versionable model. 116 | 117 | ```php 118 | class User { 119 | 120 | use VersionableTrait; 121 | 122 | // Typically hidden fields 123 | protected $hidden = ['email', 'password']; 124 | 125 | // Save these hidden fields 126 | protected $versionedHiddenFields = ['email', 'password']; 127 | 128 | } 129 | ``` 130 | 131 | 132 | 133 | ### Maximum number of stored versions 134 | 135 | You can control the maximum number of stored versions per model. By default, there will be no limit and all versions will be saved. 136 | Depending on your application, this could lead to a lot of versions, so you might want to limit the amount of stored versions. 137 | 138 | You can do this by setting a `$keepOldVersions` property on your versionable models: 139 | 140 | ```php 141 | class User { 142 | 143 | use VersionableTrait; 144 | 145 | // Keep the last 10 versions. 146 | protected $keepOldVersions = 10; 147 | 148 | } 149 | ``` 150 | 151 | 152 | 153 | ### Retrieving all versions associated to a model 154 | 155 | To retrieve all stored versions use the `versions` attribute on your model. 156 | 157 | This attribute can also be accessed like any other Laravel relation, since it is a `MorphMany` relation. 158 | 159 | ```php 160 | $model->versions; 161 | ``` 162 | 163 | 164 | 165 | ### Getting a diff of two versions 166 | 167 | If you want to know, what exactly has changed between two versions, use the version model's `diff` method. 168 | 169 | The diff method takes a version model as an argument. This defines the version to diff against. If no version is provided, it will use the current version. 170 | 171 | ```php 172 | /** 173 | * Create a diff against the current version 174 | */ 175 | $diff = $page->previousVersion()->diff(); 176 | 177 | /** 178 | * Create a diff against a specific version 179 | */ 180 | $diff = $page->currentVersion()->diff( $version ); 181 | ``` 182 | 183 | The result will be an associative array containing the attribute name as the key, and the different attribute value. 184 | 185 | 186 | 187 | ### Revert to a previous version 188 | 189 | Saving versions is pretty cool, but the real benefit will be the ability to revert to a specific version. 190 | 191 | There are multiple ways to do this. 192 | 193 | **Revert to the previous version** 194 | 195 | You can easily revert to the version prior to the currently active version using: 196 | 197 | ```php 198 | $content->previousVersion()->revert(); 199 | ``` 200 | 201 | **Revert to a specific version ID** 202 | 203 | You can also revert to a specific version ID of a model using: 204 | 205 | ```php 206 | $revertedModel = Version::find( $version_id )->revert(); 207 | ``` 208 | 209 | 210 | 211 | ### Disable versioning 212 | 213 | In some situations you might want to disable versioning a specific model completely for the current request. 214 | 215 | You can do this by using the `disableVersioning` and `enableVersioning` methods on the versionable model. 216 | 217 | ```php 218 | $user = User::find(1); 219 | $user->disableVersioning(); 220 | 221 | // This will not create a new version entry. 222 | $user->update([ 223 | 'some_attribute' => 'changed value' 224 | ]); 225 | ``` 226 | 227 | 228 | 229 | ### Use different version table 230 | 231 | Some times we want to have models versions in differents tables. By default versions are stored in the table 'versions', defined in Mpociot\Versionable\Version::$table. 232 | 233 | To use a different table to store version for some model we have to change the table name. To do so, create a model that extends Mpociot\Versionable\Version and set the $table property to another table name. 234 | 235 | ```php 236 | class MyModelVersion extends Version 237 | { 238 | $table = 'mymodel_versions'; 239 | // ... 240 | } 241 | ``` 242 | 243 | In the model that you want it use this specific versions table, use the `VersionableTrait` Trait and add the property `$versionClass` with value the specific version model. 244 | 245 | ```php 246 | class MyModel extends Eloquent 247 | { 248 | use VersionableTrait ; 249 | protected $versionClass = MyModelVersion::class ; 250 | // ... 251 | } 252 | ``` 253 | 254 | And do not forget to create a migration for this versions table, exactly as the default versions table. 255 | 256 | 257 | 258 | ## License 259 | 260 | Versionable is free software distributed under the terms of the [MIT license](https://opensource.org/licenses/MIT). 261 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mpociot/versionable", 3 | "license": "MIT", 4 | "description": "Allows to create Laravel 4 / 5 / 6 / 7 / 8 / 9 / 10 / 11 Model versioning and restoring", 5 | "keywords": [ 6 | "model", 7 | "laravel", 8 | "ardent", 9 | "version", 10 | "history", 11 | "restore" 12 | ], 13 | "homepage": "http://github.com/mpociot/versionable", 14 | "authors": [ 15 | { 16 | "name": "Marcel Pociot", 17 | "email": "m.pociot@gmail.com" 18 | } 19 | ], 20 | "support": { 21 | "issues": "https://github.com/mpociot/versionable/issues", 22 | "source": "https://github.com/mpociot/versionable" 23 | }, 24 | "require": { 25 | "php": ">=7.1.0 || >=7.2.5 || >=8.0 || >=8.1 || >= 8.2", 26 | "illuminate/support": "~5.3 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.5.3", 30 | "mockery/mockery": "^1.0", 31 | "orchestra/testbench": "^3.1 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" 32 | }, 33 | "autoload": { 34 | "classmap": [ 35 | "src/migrations", 36 | "tests/VersionableTestCase.php" 37 | ], 38 | "psr-0": { 39 | "Mpociot\\Versionable": "src/" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "phpunit" 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "providers": [ 48 | "Mpociot\\Versionable\\Providers\\ServiceProvider" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/Mpociot/ 6 | 7 | 8 | 9 | 10 | tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Mpociot/Versionable/Providers/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../../../config/config.php', 'versionable'); 17 | } 18 | 19 | /** 20 | * Perform post-registration booting of services. 21 | * 22 | * @return void 23 | */ 24 | public function boot() 25 | { 26 | $this->publishes([ 27 | __DIR__.'/../../../config/config.php' => config_path('versionable.php'), 28 | ], 'config'); 29 | 30 | $this->publishes([ 31 | __DIR__ . '/../../../migrations/' => database_path('/migrations'), 32 | ], 'migrations'); 33 | 34 | // $this->loadMigrationsFrom(__DIR__.'/../../../migrations'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Mpociot/Versionable/Version.php: -------------------------------------------------------------------------------- 1 | morphTo(); 32 | } 33 | 34 | /** 35 | * Return the user responsible for this version 36 | * @return mixed 37 | */ 38 | public function getResponsibleUserAttribute() 39 | { 40 | $model = Config::get("auth.providers.users.model"); 41 | return $model::find($this->user_id); 42 | } 43 | 44 | /** 45 | * Return the versioned model 46 | * @return Model 47 | */ 48 | public function getModel() 49 | { 50 | $modelData = is_resource($this->model_data) 51 | ? stream_get_contents($this->model_data,-1,0) 52 | : $this->model_data; 53 | 54 | $className = self::getActualClassNameForMorph($this->versionable_type); 55 | $model = new $className(); 56 | $model->unguard(); 57 | $model->fill(unserialize($modelData)); 58 | $model->exists = true; 59 | $model->reguard(); 60 | return $model; 61 | } 62 | 63 | 64 | /** 65 | * Revert to the stored model version make it the current version 66 | * 67 | * @return Model 68 | */ 69 | public function revert() 70 | { 71 | $model = $this->getModel(); 72 | unset( $model->{$model->getCreatedAtColumn()} ); 73 | unset( $model->{$model->getUpdatedAtColumn()} ); 74 | if (method_exists($model, 'getDeletedAtColumn')) { 75 | unset( $model->{$model->getDeletedAtColumn()} ); 76 | } 77 | $model->save(); 78 | return $model; 79 | } 80 | 81 | /** 82 | * Diff the attributes of this version model against another version. 83 | * If no version is provided, it will be diffed against the current version. 84 | * 85 | * @param Version|null $againstVersion 86 | * @return array 87 | */ 88 | public function diff(Version $againstVersion = null) 89 | { 90 | $model = $this->getModel(); 91 | $diff = $againstVersion ? $againstVersion->getModel() : $this->versionable()->withTrashed()->first()->currentVersion()->getModel(); 92 | 93 | $diffArray = array_diff_assoc($diff->getAttributes(), $model->getAttributes()); 94 | 95 | if (isset( $diffArray[ $model->getCreatedAtColumn() ] )) { 96 | unset( $diffArray[ $model->getCreatedAtColumn() ] ); 97 | } 98 | if (isset( $diffArray[ $model->getUpdatedAtColumn() ] )) { 99 | unset( $diffArray[ $model->getUpdatedAtColumn() ] ); 100 | } 101 | if (method_exists($model, 'getDeletedAtColumn') && isset( $diffArray[ $model->getDeletedAtColumn() ] )) { 102 | unset( $diffArray[ $model->getDeletedAtColumn() ] ); 103 | } 104 | 105 | return $diffArray; 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/Mpociot/Versionable/VersionableTrait.php: -------------------------------------------------------------------------------- 1 | versionClass; 26 | } 27 | 28 | return config('versionable.version_model', Version::class); 29 | } 30 | 31 | /** 32 | * Private variable to detect if this is an update 33 | * or an insert. 34 | * @var bool 35 | */ 36 | private $updating; 37 | 38 | /** 39 | * Contains all dirty data that is valid for versioning. 40 | * 41 | * @var array 42 | */ 43 | private $versionableDirtyData; 44 | 45 | /** 46 | * Optional reason, why this version was created. 47 | * @var string 48 | */ 49 | private $reason; 50 | 51 | /** 52 | * Flag that determines if the model allows versioning at all. 53 | * @var bool 54 | */ 55 | protected $versioningEnabled = true; 56 | 57 | /** 58 | * @return $this 59 | */ 60 | public function enableVersioning() 61 | { 62 | $this->versioningEnabled = true; 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return $this 68 | */ 69 | public function disableVersioning() 70 | { 71 | $this->versioningEnabled = false; 72 | return $this; 73 | } 74 | 75 | /** 76 | * Attribute mutator for "reason". 77 | * Prevent "reason" to become a database attribute of model. 78 | * 79 | * @param string $value 80 | */ 81 | public function setReasonAttribute($value) 82 | { 83 | $this->reason = $value; 84 | } 85 | 86 | /** 87 | * Initialize model events. 88 | */ 89 | public static function bootVersionableTrait() 90 | { 91 | static::saving(function ($model) { 92 | $model->versionablePreSave(); 93 | }); 94 | 95 | static::saved(function ($model) { 96 | $model->versionablePostSave(); 97 | }); 98 | 99 | } 100 | 101 | /** 102 | * Return all versions of the model. 103 | * @return MorphMany 104 | */ 105 | public function versions() 106 | { 107 | return $this->morphMany( $this->getVersionClass(), 'versionable'); 108 | } 109 | 110 | /** 111 | * Returns the latest version available. 112 | * @return Version 113 | */ 114 | public function currentVersion() 115 | { 116 | return $this->getLatestVersions()->first(); 117 | } 118 | 119 | /** 120 | * Returns the previous version. 121 | * @return Version 122 | */ 123 | public function previousVersion() 124 | { 125 | return $this->getLatestVersions()->limit(1)->offset(1)->first(); 126 | } 127 | 128 | /** 129 | * Get a model based on the version id. 130 | * 131 | * @param $version_id 132 | * 133 | * @return $this|null 134 | */ 135 | public function getVersionModel($version_id) 136 | { 137 | $version = $this->versions()->where("version_id", "=", $version_id)->first(); 138 | if (!is_null($version)) { 139 | return $version->getModel(); 140 | } 141 | return null; 142 | } 143 | 144 | /** 145 | * Pre save hook to determine if versioning is enabled and if we're updating 146 | * the model. 147 | * @return void 148 | */ 149 | protected function versionablePreSave() 150 | { 151 | if ($this->versioningEnabled === true) { 152 | $this->versionableDirtyData = $this->getDirty(); 153 | $this->updating = $this->exists; 154 | } 155 | } 156 | 157 | /** 158 | * Save a new version. 159 | * @return void 160 | */ 161 | protected function versionablePostSave() 162 | { 163 | /** 164 | * We'll save new versions on updating and first creation. 165 | */ 166 | if ( 167 | ( $this->versioningEnabled === true && $this->updating && $this->isValidForVersioning() ) || 168 | ( $this->versioningEnabled === true && !$this->updating && !is_null($this->versionableDirtyData) && count($this->versionableDirtyData)) 169 | ) { 170 | // Save a new version 171 | $class = $this->getVersionClass(); 172 | $version = new $class(); 173 | $version->versionable_id = $this->getKey(); 174 | $version->versionable_type = method_exists($this, 'getMorphClass') ? $this->getMorphClass() : get_class($this); 175 | $version->user_id = $this->getAuthUserId(); 176 | 177 | $versionedHiddenFields = $this->versionedHiddenFields ?? []; 178 | $this->makeVisible($versionedHiddenFields); 179 | $version->model_data = serialize($this->attributesToArray()); 180 | $this->makeHidden($versionedHiddenFields); 181 | 182 | if (!empty( $this->reason )) { 183 | $version->reason = $this->reason; 184 | } 185 | 186 | $version->save(); 187 | 188 | $this->purgeOldVersions(); 189 | } 190 | } 191 | 192 | 193 | /** 194 | * Initialize a version on every instance of a model. 195 | * @return void 196 | */ 197 | public static function initializeVersions() 198 | { 199 | foreach (self::all() as $obj) { 200 | $obj->createInitialVersion(); 201 | } 202 | } 203 | 204 | /** 205 | * Save a new version. 206 | * @return void 207 | */ 208 | public function createInitialVersion() 209 | { 210 | if( true === $this->fresh()->versions->isEmpty() && 211 | true === $this->versioningEnabled 212 | ) { 213 | 214 | $class = $this->getVersionClass(); 215 | $version = new $class(); 216 | $version->versionable_id = $this->getKey(); 217 | $version->versionable_type = method_exists($this, 'getMorphClass') ? $this->getMorphClass() : get_class($this); 218 | $version->user_id = $this->getAuthUserId(); 219 | 220 | $versionedHiddenFields = $this->versionedHiddenFields ?? []; 221 | $this->makeVisible($versionedHiddenFields); 222 | $version->model_data = serialize($this->attributesToArray()); 223 | $this->makeHidden($versionedHiddenFields); 224 | 225 | if (!empty( $this->reason )) { 226 | $version->reason = $this->reason; 227 | } 228 | 229 | $version->save(); 230 | } 231 | } 232 | 233 | 234 | /** 235 | * Delete old versions of this model when they reach a specific count. 236 | * 237 | * @return void 238 | */ 239 | private function purgeOldVersions() 240 | { 241 | $keep = isset($this->keepOldVersions) ? $this->keepOldVersions : 0; 242 | 243 | if ((int)$keep > 0) { 244 | $count = $this->versions()->count(); 245 | 246 | if ($count > $keep) { 247 | $this->getLatestVersions() 248 | ->take($count) 249 | ->skip($keep) 250 | ->get() 251 | ->each(function ($version) { 252 | $version->delete(); 253 | }); 254 | } 255 | } 256 | } 257 | 258 | /** 259 | * Determine if a new version should be created for this model. 260 | * Checks if appropriate fields have been changed. 261 | * 262 | * @return bool 263 | */ 264 | private function isValidForVersioning() 265 | { 266 | $removeableKeys = isset( $this->dontVersionFields ) ? $this->dontVersionFields : []; 267 | if (($updatedAt = $this->getUpdatedAtColumn()) !== null) { 268 | $removeableKeys[] = $updatedAt; 269 | } 270 | 271 | if (method_exists($this, 'getDeletedAtColumn') && ($deletedAt = $this->getDeletedAtColumn()) !== null) { 272 | $removeableKeys[] = $deletedAt; 273 | } 274 | 275 | return ( count(array_diff_key($this->versionableDirtyData, array_flip($removeableKeys))) > 0 ); 276 | } 277 | 278 | /** 279 | * @return int|null 280 | */ 281 | protected function getAuthUserId() 282 | { 283 | return Auth::check() ? Auth::id() : null; 284 | } 285 | 286 | /** 287 | * @return \Illuminate\Database\Eloquent\Builder 288 | */ 289 | protected function getLatestVersions() 290 | { 291 | return $this->versions()->orderByDesc('version_id'); 292 | } 293 | 294 | 295 | } 296 | -------------------------------------------------------------------------------- /src/config/config.php: -------------------------------------------------------------------------------- 1 | \Mpociot\Versionable\Version::class 11 | 12 | ]; -------------------------------------------------------------------------------- /src/migrations/2014_09_27_212641_create_versions_table.php: -------------------------------------------------------------------------------- 1 | increments('version_id'); 19 | $table->string('versionable_id'); 20 | $table->string('versionable_type'); 21 | $table->string('user_id')->nullable(); 22 | $table->longText('model_data'); 23 | $table->string('reason', 100)->nullable(); 24 | $table->index('versionable_id'); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::drop('versions'); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /tests/VersionableTest.php: -------------------------------------------------------------------------------- 1 | name = "Nono"; 25 | $user->email = "nono.ma@test.php"; 26 | $user->password = "12345"; 27 | $user->last_login = $user->freshTimestamp(); 28 | $user->save(); 29 | 30 | $version = $user->currentVersion(); 31 | $this->assertInstanceOf( TestVersionableUser::class, $version->versionable ); 32 | } 33 | 34 | public function testInitialSaveShouldCreateVersion() 35 | { 36 | $user = new TestVersionableUser(); 37 | $user->name = "Nono"; 38 | $user->email = "nono.ma@test.php"; 39 | $user->password = "12345"; 40 | $user->last_login = $user->freshTimestamp(); 41 | $user->save(); 42 | 43 | $this->assertCount(1, $user->versions ); 44 | } 45 | 46 | public function testRetrievePreviousVersionFails() 47 | { 48 | $user = new TestVersionableUser(); 49 | $user->name = "Nono"; 50 | $user->email = "nono.ma@test.php"; 51 | $user->password = "12345"; 52 | $user->last_login = $user->freshTimestamp(); 53 | $user->save(); 54 | 55 | $this->assertCount(1, $user->versions ); 56 | $this->assertNull( $user->previousVersion() ); 57 | } 58 | 59 | public function testRetrievePreviousVersionExists() 60 | { 61 | $user = new TestVersionableUser(); 62 | $user->name = "Nono"; 63 | $user->email = "nono.ma@test.php"; 64 | $user->password = "12345"; 65 | $user->last_login = $user->freshTimestamp(); 66 | $user->save(); 67 | 68 | $user->name = "John"; 69 | $user->save(); 70 | 71 | $this->assertCount(2, $user->versions ); 72 | $this->assertNotNull( $user->previousVersion() ); 73 | 74 | $this->assertEquals( "Nono", $user->previousVersion()->getModel()->name ); 75 | } 76 | 77 | public function testVersionAndModelAreEqual() 78 | { 79 | $user = new TestVersionableUser(); 80 | $user->name = "Nono"; 81 | $user->email = "nono.ma@test.php"; 82 | $user->password = "12345"; 83 | $user->last_login = $user->freshTimestamp(); 84 | $user->save(); 85 | 86 | $version = $user->currentVersion(); 87 | $this->assertEquals( $user->attributesToArray(), $version->getModel()->attributesToArray() ); 88 | } 89 | 90 | 91 | public function testVersionsAreRelatedToUsers() 92 | { 93 | $user_id = rand(1,100); 94 | 95 | Auth::shouldReceive('check') 96 | ->andReturn( true ); 97 | 98 | Auth::shouldReceive('id') 99 | ->andReturn( $user_id ); 100 | 101 | $user = new TestVersionableUser(); 102 | $user->name = "Nono"; 103 | $user->email = "nono.ma@test.php"; 104 | $user->password = "12345"; 105 | $user->last_login = $user->freshTimestamp(); 106 | $user->save(); 107 | 108 | $version = $user->currentVersion(); 109 | 110 | $this->assertEquals( $user_id, $version->user_id ); 111 | } 112 | 113 | public function testGetResponsibleUserAttribute() 114 | { 115 | $responsibleOrigUser = new TestVersionableUser(); 116 | $responsibleOrigUser->name = "Nono"; 117 | $responsibleOrigUser->email = "nono.ma@test.php"; 118 | $responsibleOrigUser->password = "12345"; 119 | $responsibleOrigUser->last_login = $responsibleOrigUser->freshTimestamp(); 120 | $responsibleOrigUser->save(); 121 | 122 | auth()->login($responsibleOrigUser); 123 | 124 | $user = new TestVersionableUser(); 125 | $user->name = "John"; 126 | $user->email = "j.tester@test.php"; 127 | $user->password = "67890"; 128 | $user->last_login = $user->freshTimestamp(); 129 | $user->save(); 130 | 131 | $version = $user->currentVersion(); 132 | 133 | $responsibleUser = $version->responsible_user; 134 | $this->assertEquals( $responsibleUser->getKey(), 1 ); 135 | $this->assertEquals( $responsibleUser->name, $responsibleOrigUser->name ); 136 | $this->assertEquals( $responsibleUser->email, $responsibleOrigUser->email ); 137 | } 138 | 139 | 140 | public function testDontVersionEveryAttribute() 141 | { 142 | $user = new TestPartialVersionableUser(); 143 | $user->name = "Nono"; 144 | $user->email = "nono.ma@test.php"; 145 | $user->password = "12345"; 146 | $user->last_login = $user->freshTimestamp(); 147 | $user->save(); 148 | 149 | 150 | $user->last_login = $user->freshTimestamp(); 151 | $user->save(); 152 | 153 | $this->assertCount( 1, $user->versions ); 154 | } 155 | 156 | public function testVersionEveryAttribute() 157 | { 158 | $user = new TestVersionableUser(); 159 | $user->name = "Nono"; 160 | $user->email = "nono.ma@test.php"; 161 | $user->password = "12345"; 162 | $user->last_login = $user->freshTimestamp(); 163 | $user->save(); 164 | 165 | $user->last_login = $user->freshTimestamp(); 166 | $user->save(); 167 | 168 | $this->assertCount( 2, $user->versions ); 169 | } 170 | 171 | public function testCheckForVersioningEnabled() 172 | { 173 | $user = new TestVersionableUser(); 174 | $user->disableVersioning(); 175 | 176 | $user->name = "Nono"; 177 | $user->email = "nono.ma@test.php"; 178 | $user->password = "12345"; 179 | $user->last_login = $user->freshTimestamp(); 180 | $user->save(); 181 | 182 | $user->last_login = $user->freshTimestamp(); 183 | $user->save(); 184 | 185 | $this->assertCount( 0, $user->versions()->get() ); 186 | 187 | $user->enableVersioning(); 188 | $user->last_login = $user->freshTimestamp(); 189 | $user->save(); 190 | 191 | $this->assertCount( 1, $user->versions()->get() ); 192 | } 193 | 194 | 195 | public function testCheckForVersioningEnabledLaterOn() 196 | { 197 | $user = new TestVersionableUser(); 198 | 199 | $user->name = "Nono"; 200 | $user->email = "nono.ma@test.php"; 201 | $user->password = "12345"; 202 | $user->last_login = $user->freshTimestamp(); 203 | $user->save(); 204 | $user->disableVersioning(); 205 | 206 | $user->last_login = $user->freshTimestamp(); 207 | $user->save(); 208 | 209 | $this->assertCount( 1, $user->versions ); 210 | } 211 | 212 | public function testCanRevertVersion() 213 | { 214 | $user = new TestVersionableUser(); 215 | 216 | $user->name = "Nono"; 217 | $user->email = "nono.ma@test.php"; 218 | $user->password = "12345"; 219 | $user->last_login = $user->freshTimestamp(); 220 | $user->save(); 221 | 222 | $user_id = $user->getKey(); 223 | 224 | $user->name = "John"; 225 | $user->save(); 226 | 227 | $newUser = TestVersionableUser::find( $user_id ); 228 | $this->assertEquals( "John", $newUser->name ); 229 | 230 | // Fetch first version and revert ist 231 | $newUser->versions()->first()->revert(); 232 | 233 | $newUser = TestVersionableUser::find( $user_id ); 234 | $this->assertEquals( "Nono", $newUser->name ); 235 | } 236 | 237 | public function testCanRevertSoftDeleteVersion() 238 | { 239 | $user = new TestVersionableSoftDeleteUser(); 240 | 241 | $user->name = "Nono"; 242 | $user->email = "nono.ma@test.php"; 243 | $user->password = "12345"; 244 | $user->last_login = $user->freshTimestamp(); 245 | $user->save(); 246 | 247 | $user_id = $user->getKey(); 248 | 249 | $user->name = "John"; 250 | $user->save(); 251 | 252 | $newUser = TestVersionableSoftDeleteUser::find( $user_id ); 253 | $this->assertEquals( "John", $newUser->name ); 254 | 255 | // Fetch first version and revert ist 256 | $reverted = $newUser->versions()->first()->revert(); 257 | 258 | $newUser = TestVersionableSoftDeleteUser::find( $user_id ); 259 | $this->assertEquals( "Nono", $reverted->name ); 260 | $this->assertEquals( "Nono", $newUser->name ); 261 | } 262 | 263 | public function testGetVersionModel() 264 | { 265 | // Create 3 versions 266 | $user = new TestVersionableUser(); 267 | $user->name = "Nono"; 268 | $user->email = "nono.ma@test.php"; 269 | $user->password = "12345"; 270 | $user->last_login = $user->freshTimestamp(); 271 | $user->save(); 272 | 273 | $user->name = "John"; 274 | $user->save(); 275 | 276 | $user->name = "Michael"; 277 | $user->save(); 278 | 279 | $this->assertCount( 3, $user->versions ); 280 | 281 | $this->assertEquals( "Nono", $user->getVersionModel( 1 )->name ); 282 | $this->assertEquals( "John", $user->getVersionModel( 2 )->name ); 283 | $this->assertEquals( "Michael", $user->getVersionModel( 3 )->name ); 284 | $this->assertEquals( null, $user->getVersionModel( 4 ) ); 285 | 286 | } 287 | 288 | public function testGetVersionModelWithJsonField() 289 | { 290 | $model = new ModelWithJsonField(); 291 | $model->json_field = ["foo" => "bar"]; 292 | $model->save(); 293 | 294 | $this->assertEquals(["foo" => "bar"], $model->getVersionModel(1)->json_field); 295 | } 296 | 297 | public function testUseReasonAttribute() 298 | { 299 | // Create 3 versions 300 | $user = new TestVersionableUser(); 301 | $user->name = "Nono"; 302 | $user->email = "nono.ma@test.php"; 303 | $user->password = "12345"; 304 | $user->last_login = $user->freshTimestamp(); 305 | $user->reason = "Doing tests"; 306 | $user->save(); 307 | 308 | $this->assertEquals( "Doing tests", $user->currentVersion()->reason ); 309 | } 310 | 311 | public function testIgnoreDeleteTimestamp() 312 | { 313 | $user = new TestVersionableSoftDeleteUser(); 314 | $user->name = "Nono"; 315 | $user->email = "nono.ma@test.php"; 316 | $user->password = "12345"; 317 | $user->last_login = $user->freshTimestamp(); 318 | $user->save(); 319 | 320 | $this->assertCount( 1 , $user->versions ); 321 | $user_id = $user->getKey(); 322 | $this->assertNull( $user->deleted_at ); 323 | 324 | $user->delete(); 325 | 326 | $this->assertNotNull( $user->deleted_at ); 327 | 328 | $this->assertCount( 1 , $user->versions ); 329 | } 330 | 331 | public function testDiffTwoVersions() 332 | { 333 | 334 | $user = new TestVersionableUser(); 335 | $user->name = "Nono"; 336 | $user->email = "nono.ma@test.php"; 337 | $user->password = "12345"; 338 | $user->last_login = $user->freshTimestamp(); 339 | $user->save(); 340 | 341 | $user->name = "John"; 342 | $user->save(); 343 | 344 | $diff = $user->previousVersion()->diff(); 345 | $this->assertTrue( is_array($diff) ); 346 | 347 | $this->assertCount(1, $diff); 348 | $this->assertEquals( "John", $diff["name"] ); 349 | } 350 | 351 | public function testDiffIgnoresTimestamps() 352 | { 353 | $user = new TestVersionableSoftDeleteUser(); 354 | $user->name = "Nono"; 355 | $user->email = "nono.ma@test.php"; 356 | $user->password = "12345"; 357 | $user->last_login = $user->freshTimestamp(); 358 | $user->save(); 359 | 360 | $user->name = "John"; 361 | $user->created_at = Carbon::now(); 362 | $user->updated_at = Carbon::now(); 363 | $user->deleted_at = Carbon::now(); 364 | $user->save(); 365 | 366 | $diff = $user->previousVersion()->diff(); 367 | $this->assertTrue( is_array($diff) ); 368 | 369 | $this->assertCount(1, $diff); 370 | $this->assertEquals( "John", $diff["name"] ); 371 | } 372 | 373 | public function testDiffSpecificVersions() 374 | { 375 | // Create 3 versions 376 | $user = new TestVersionableSoftDeleteUser(); 377 | $user->name = "Nono"; 378 | $user->email = "nono.ma@test.php"; 379 | $user->password = "12345"; 380 | $user->last_login = $user->freshTimestamp(); 381 | $user->save(); 382 | 383 | $user->name = "John"; 384 | $user->email = "john@snow.com"; 385 | $user->save(); 386 | 387 | $user->name = "Julia"; 388 | $user->save(); 389 | 390 | $diff = $user->currentVersion()->diff( $user->versions()->orderBy("version_id","ASC")->first() ); 391 | $this->assertTrue( is_array($diff) ); 392 | 393 | $this->assertCount(2, $diff); 394 | $this->assertEquals( "Nono", $diff["name"] ); 395 | $this->assertEquals( "nono.ma@test.php", $diff["email"] ); 396 | 397 | 398 | $diff = $user->currentVersion()->diff( $user->versions()->orderBy("version_id","ASC")->offset(1)->first() ); 399 | $this->assertTrue( is_array($diff) ); 400 | 401 | $this->assertCount(1, $diff); 402 | $this->assertEquals( "John", $diff["name"] ); 403 | } 404 | 405 | public function testDynamicVersionModel() 406 | { 407 | $name_v1 = 'first' ; 408 | $name_v2 = 'second' ; 409 | 410 | $model = new ModelWithDynamicVersion(); 411 | $model->name = $name_v1 ; 412 | $model->save(); 413 | 414 | $model->name = $name_v2 ; 415 | $model->save(); 416 | 417 | // Assert that no row in default Version table 418 | $this->assertEquals( 0, Version::all()->count() ); 419 | 420 | // But are in Custom version table 421 | $this->assertEquals( 2, DynamicVersionModel::all()->count() ); 422 | 423 | // Assert that some versions exist 424 | $this->assertEquals( 2, $model->versions->count() ); 425 | $this->assertEquals( $name_v2, $model->name ); 426 | $this->assertArrayHasKey( 'name', $model->previousVersion()->diff()); 427 | 428 | // Test the revert 429 | $model = $model->previousVersion()->revert(); 430 | 431 | $this->assertEquals( $name_v1, $model->name ); 432 | } 433 | 434 | public function testItUsesConfigurableVersionClass() 435 | { 436 | $this->app['config']->set('versionable.version_model', DynamicVersionModel::class); 437 | 438 | 439 | $name_v1 = 'first' ; 440 | $name_v2 = 'second' ; 441 | 442 | $model = new TestVersionableUser(); 443 | $model->name = $name_v1 ; 444 | $model->email = $name_v1 ; 445 | $model->password = $name_v1 ; 446 | $model->save(); 447 | 448 | $model->name = $name_v2 ; 449 | $model->save(); 450 | 451 | // Assert that no row in default Version table 452 | $this->assertCount(0, Version::all()); 453 | 454 | // But are in Custom version table 455 | $this->assertCount(2, DynamicVersionModel::all()); 456 | } 457 | 458 | public function testKeepMaxVersionCount() 459 | { 460 | $name_v1 = 'first' ; 461 | $name_v2 = 'second' ; 462 | $name_v3 = 'third' ; 463 | $name_v4 = 'fourth' ; 464 | 465 | $model = new ModelWithMaxVersions(); 466 | $model->email = "nono.ma@test.php"; 467 | $model->password = "foo"; 468 | $model->name = $name_v1 ; 469 | $model->save(); 470 | 471 | $model->name = $name_v2 ; 472 | $model->save(); 473 | 474 | $model->name = $name_v3 ; 475 | $model->save(); 476 | 477 | $model->name = $name_v4 ; 478 | $model->save(); 479 | 480 | // We limit the versions to only keep the latest one. 481 | $this->assertEquals( 2, Version::all()->count() ); 482 | 483 | $this->assertEquals( 2, $model->versions()->count() ); 484 | 485 | $this->assertArrayHasKey( 'name', $model->previousVersion()->diff()); 486 | 487 | // Test the revert 488 | $model = $model->previousVersion()->revert(); 489 | 490 | $this->assertEquals( $name_v3, $model->name ); 491 | } 492 | 493 | public function testAllowHiddenFields() { 494 | $user = new TestHiddenFieldsUser(); 495 | $user->name = "Nono"; 496 | $user->email = "nono.ma@test.php"; 497 | $user->password = "12345"; 498 | $user->save(); 499 | sleep(1); 500 | 501 | $user->name = "John"; 502 | $user->email = "j.barlow@test.php"; 503 | $user->password = "6789"; 504 | $user->save(); 505 | sleep(1); 506 | 507 | $diff = $user->previousVersion()->diff(); 508 | 509 | $this->assertArrayHasKey('email', $diff); 510 | $this->assertArrayHasKey('password', $diff); 511 | $this->assertEquals( 'John', $diff['name'] ); 512 | $this->assertEquals( 'j.barlow@test.php', $diff['email'] ); 513 | $this->assertEquals( '6789', $diff['password'] ); 514 | 515 | $this->assertArrayNotHasKey('password', $user->toArray()); 516 | } 517 | 518 | public function testWhereModelHasMorphMap() 519 | { 520 | Relation::morphMap(['users' => TestVersionableUser::class]); 521 | $user = new TestVersionableUser(); 522 | $user->name = "Test"; 523 | $user->email = "example@test.php"; 524 | $user->password = "12345"; 525 | $user->last_login = $user->freshTimestamp(); 526 | $user->save(); 527 | 528 | $version = $user->currentVersion(); 529 | $this->assertEquals( $user->attributesToArray(), $version->getModel()->attributesToArray() ); 530 | Relation::morphMap([], false); 531 | } 532 | 533 | public function testAddVersionableToExistingUser() 534 | { 535 | $user = new \Illuminate\Foundation\Auth\User(); 536 | $user->name = "Nono"; 537 | $user->email = "nono.ma@bmail.php"; 538 | $user->password = "12345"; 539 | $user->save(); 540 | 541 | $this->assertNull($user->versions ); 542 | 543 | $user = TestVersionableUser::find($user->id); 544 | $this->assertCount(0, $user->versions ); 545 | $user->createInitialVersion(); 546 | $this->assertCount(1, $user->fresh()->versions ); 547 | 548 | //ASSERT THAT createInitialVersion() ONLY WORKS ONCE 549 | $user->createInitialVersion(); 550 | $this->assertCount(1, $user->fresh()->versions ); 551 | } 552 | 553 | public function testInitializeModel() 554 | { 555 | $user = new \Illuminate\Foundation\Auth\User(); 556 | $user->name = "Nono"; 557 | $user->email = "nono.ma@bmail.php"; 558 | $user->password = "12345"; 559 | $user->save(); 560 | 561 | $this->assertNull($user->versions ); 562 | 563 | $user = TestVersionableUser::find($user->id); 564 | $this->assertCount(0, $user->versions ); 565 | 566 | TestVersionableUser::initializeVersions(); 567 | $this->assertCount(1, $user->fresh()->versions ); 568 | 569 | //ASSERT THAT createInitialVersion() ONLY WORKS ONCE 570 | TestVersionableUser::initializeVersions(); 571 | $this->assertCount(1, $user->fresh()->versions ); 572 | } 573 | 574 | 575 | } 576 | 577 | 578 | 579 | 580 | class TestVersionableUser extends \Illuminate\Foundation\Auth\User { 581 | use \Mpociot\Versionable\VersionableTrait; 582 | 583 | protected $table = "users"; 584 | } 585 | 586 | class TestVersionableSoftDeleteUser extends Illuminate\Database\Eloquent\Model { 587 | use \Mpociot\Versionable\VersionableTrait; 588 | use \Illuminate\Database\Eloquent\SoftDeletes; 589 | 590 | protected $table = "users"; 591 | } 592 | 593 | class ModelWithMaxVersions extends Illuminate\Database\Eloquent\Model { 594 | use \Mpociot\Versionable\VersionableTrait; 595 | 596 | protected $table = "users"; 597 | 598 | protected $keepOldVersions = 2; 599 | } 600 | 601 | class TestPartialVersionableUser extends Illuminate\Database\Eloquent\Model { 602 | use \Mpociot\Versionable\VersionableTrait; 603 | 604 | protected $table = "users"; 605 | 606 | protected $dontVersionFields = ["last_login"]; 607 | } 608 | 609 | 610 | class DynamicVersionModel extends Version 611 | { 612 | const TABLENAME = 'other_versions'; 613 | public $table = self::TABLENAME ; 614 | } 615 | class ModelWithDynamicVersion extends Model 616 | { 617 | const TABLENAME = 'some_data'; 618 | public $table = self::TABLENAME ; 619 | //use DynamicVersionModelTrait; 620 | use VersionableTrait ; 621 | protected $versionClass = DynamicVersionModel::class ; 622 | } 623 | class ModelWithJsonField extends Model 624 | { 625 | const TABLENAME = 'table_with_json_field'; 626 | public $table = self::TABLENAME ; 627 | use VersionableTrait ; 628 | protected $casts = ['json_field' => 'array']; 629 | } 630 | 631 | class TestHiddenFieldsUser extends \Illuminate\Foundation\Auth\User { 632 | use \Mpociot\Versionable\VersionableTrait; 633 | 634 | protected $table = "users"; 635 | 636 | protected $hidden = ['email', 'password']; 637 | 638 | protected $versionedHiddenFields = ['email', 'password']; 639 | } 640 | -------------------------------------------------------------------------------- /tests/VersionableTestCase.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 10 | 11 | $this->migrateUsersTable(); 12 | } 13 | 14 | protected function getEnvironmentSetUp($app) 15 | { 16 | $app['config']->set('auth.providers.users.model', TestVersionableUser::class); 17 | $app['config']->set('database.default', 'sqlite'); 18 | $app['config']->set('database.connections.sqlite', [ 19 | 'driver' => 'sqlite', 20 | 'database' => ':memory:', 21 | 'prefix' => '', 22 | ]); 23 | $app['config']->set('app.key', 'base64:6Cu/ozj4gPtIjmXjr8EdVnGFNsdRqZfHfVjQkmTlg4Y='); 24 | } 25 | 26 | protected function setUpDatabase() 27 | { 28 | include_once __DIR__ . '/../src/migrations/2014_09_27_212641_create_versions_table.php'; 29 | 30 | (new \CreateVersionsTable())->up(); 31 | } 32 | 33 | protected function getPackageProviders($app) 34 | { 35 | return [ 36 | \Mpociot\Versionable\Providers\ServiceProvider::class, 37 | ]; 38 | } 39 | 40 | public function migrateUsersTable() 41 | { 42 | $this->app['db']->connection()->getSchemaBuilder()->create('users', function ($table) { 43 | $table->increments('id'); 44 | $table->string('name'); 45 | $table->string('email'); 46 | $table->string('password'); 47 | $table->datetime('last_login')->nullable(); 48 | $table->timestamps(); 49 | $table->softDeletes(); 50 | }); 51 | 52 | $this->app['db']->connection()->getSchemaBuilder()->create(ModelWithDynamicVersion::TABLENAME, function ($table) { 53 | $table->increments('id'); 54 | $table->text('name'); 55 | $table->timestamps(); 56 | }); 57 | 58 | $this->app['db']->connection()->getSchemaBuilder()->create(DynamicVersionModel::TABLENAME, function ($table) { 59 | $table->increments('version_id'); 60 | $table->string('versionable_id'); 61 | $table->string('versionable_type'); 62 | $table->string('user_id')->nullable(); 63 | $table->binary('model_data'); 64 | $table->string('reason', 100)->nullable(); 65 | $table->index('versionable_id'); 66 | $table->timestamps(); 67 | }); 68 | 69 | $this->app['db']->connection()->getSchemaBuilder()->create(ModelWithJsonField::TABLENAME, function ($table) { 70 | $table->increments('id'); 71 | $table->json('json_field'); 72 | $table->timestamps(); 73 | }); 74 | } 75 | } --------------------------------------------------------------------------------